PAYATHON 2026

iDEAL payment 出现 SE2700 Invalid electronic signature

支付阿杰

结论

SE2700 Invalid electronic signature 表示 ING 校验商户请求的数字签名失败。这个错误通常与 expose_php 无关。该设置只决定 HTTP 响应头是否暴露 PHP 版本,不会影响 XML、证书或签名结果。

证书仍在有效期内,也不代表当前请求使用的私钥一定与 ING 后台保存的 merchant certificate 匹配。建议先检查证书与私钥是否配对、测试和生产环境是否混用、ING 后台是否保存了正确证书,以及 XML 在签名后是否又被修改。

为什么会出现这个错误

返回信息包含:

SE2700 Invalid electronic signature
System generating error: Acquirer

这说明错误由 acquirer 在接收请求时产生。问题出在 ING 验证商户发出的请求,而不是 PHP 解析响应的过程。

堆栈中的:

XmlSerializer->checkForErrorMessage()

只是 connector 收到 ING 返回的错误 XML 后抛出异常,并非签名失败的原因。

排查时需要区分两类证书:

  • merchant certificate:由商户上传至 ING,供 ING 验证商户请求。
  • acquirer certificate:由 ING 提供,供商户验证 ING 返回的响应。

因此,下载并安装 acquirer certificate 无法解决商户请求签名无效的问题。当前应重点检查 merchant private key、merchant certificate 和实际发出的 XML。

建议的排查顺序

1. 确认证书与私钥确实配对

“证书有效”通常只能说明证书尚未过期且格式可以解析,不能说明 PHP 实际加载的私钥就是该证书对应的私钥。

可以使用 OpenSSL 比较证书中的公钥和私钥导出的公钥:

openssl x509 -in merchant.crt -pubkey -noout > certificate-public.pem
openssl pkey -in merchant-private.pem -pubout > private-public.pem
diff certificate-public.pem private-public.pem

如果私钥有密码:

openssl pkey \
  -in merchant-private.pem \
  -passin pass:'PRIVATE_KEY_PASSWORD' \
  -pubout > private-public.pem

只有 diff 没有输出,才能说明两份公钥内容一致。

也可以比较公钥摘要:

openssl x509 -in merchant.crt -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256

openssl pkey -in merchant-private.pem -pubout -outform DER \
  | openssl dgst -sha256

两个 SHA-256 值必须相同。

2. 核对 ING 保存的是否正是当前证书

计算本地 merchant certificate 的指纹:

openssl x509 -in merchant.crt -noout -fingerprint -sha256

将结果与 ING 商户后台显示或确认的证书指纹进行比较。如果后台只显示 SHA-1 指纹,可以使用:

openssl x509 -in merchant.crt -noout -fingerprint -sha1

常见问题包括:

  • 重新生成了私钥,但 ING 仍保存着旧证书。
  • 上传了文件名相同、内容却不同的证书。
  • 上传的是 acquirer certificate,而不是 merchant certificate。
  • 测试环境和生产环境保存了不同证书。
  • ING 后台更新的证书尚未应用到当前 Merchant ID。
  • 程序仍从旧路径读取证书或私钥。

不要通过文件名判断证书是否正确,应直接比较证书指纹和公钥。

3. 检查程序实际加载的文件

配置文件看起来正确,不代表运行中的 PHP 进程读取了预期文件。可以记录以下诊断信息,但不要记录私钥内容或私钥密码:

<?php

$certificatePath = '/absolute/path/merchant.crt';
$privateKeyPath  = '/absolute/path/merchant-private.pem';

foreach ([$certificatePath, $privateKeyPath] as $path) {
    if (!is_file($path) || !is_readable($path)) {
        throw new RuntimeException('File is missing or unreadable: ' . $path);
    }
}

$certificatePem = file_get_contents($certificatePath);
$certificate = openssl_x509_read($certificatePem);

if ($certificate === false) {
    throw new RuntimeException('Unable to parse merchant certificate');
}

$info = openssl_x509_parse($certificate);

error_log('Merchant certificate path: ' . realpath($certificatePath));
error_log('Merchant certificate SHA-256: ' . openssl_x509_fingerprint(
    $certificate,
    'sha256'
));
error_log('Certificate validFrom: ' . ($info['validFrom_time_t'] ?? 'unknown'));
error_log('Certificate validTo: ' . ($info['validTo_time_t'] ?? 'unknown'));

加载私钥时,还要检查密码和 OpenSSL 错误队列:

<?php

$privateKeyPem = file_get_contents($privateKeyPath);
$privateKey = openssl_pkey_get_private(
    $privateKeyPem,
    $privateKeyPassword
);

if ($privateKey === false) {
    $errors = [];

    while (($error = openssl_error_string()) !== false) {
        $errors[] = $error;
    }

    throw new RuntimeException(
        'Unable to load private key: ' . implode('; ', $errors)
    );
}

如果项目使用相对路径,CLI、PHP-FPM 和 Web Server 的工作目录可能并不相同。证书配置最好使用绝对路径,并通过 realpath() 确认最终加载的位置。

4. 在本地验证私钥和证书能否完成签名校验

下面的代码不是用来生成 iDEAL 协议签名的,只用于确认 PHP 当前加载的私钥与证书公钥匹配:

<?php

$data = 'iDEAL local signature diagnostic';

$certificatePem = file_get_contents('/absolute/path/merchant.crt');
$privateKeyPem  = file_get_contents('/absolute/path/merchant-private.pem');

$privateKey = openssl_pkey_get_private(
    $privateKeyPem,
    $privateKeyPassword
);

$publicKey = openssl_pkey_get_public($certificatePem);

if ($privateKey === false || $publicKey === false) {
    throw new RuntimeException('Unable to load certificate or private key');
}

if (!openssl_sign($data, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
    throw new RuntimeException('Local signing failed');
}

$result = openssl_verify(
    $data,
    $signature,
    $publicKey,
    OPENSSL_ALGO_SHA256
);

if ($result !== 1) {
    throw new RuntimeException(
        'The private key does not match the merchant certificate'
    );
}

这里使用 SHA-256 只是为了进行本地配对诊断。实际 iDEAL 请求必须使用对应协议版本和 ING 接口文档规定的签名算法,不能直接拿这段代码替换 connector 的签名实现。

5. 检查测试环境与生产环境是否混用

以下配置必须属于同一套环境:

  • Merchant ID
  • Sub ID
  • merchant certificate
  • merchant private key
  • acquirer certificate
  • ING endpoint
  • iDEAL 协议版本

例如,将生产 Merchant ID 与测试 endpoint 配合使用,或者用生产证书签名后向测试环境发送请求,都可能遭到拒绝。

还要检查 Merchant ID 是否包含前导零。它通常应以字符串形式保存和传递,不能先转换成整数:

$merchantId = '001234567';

不要写成:

$merchantId = 1234567;

不过,Merchant ID 有误时通常也可能返回其他错误码。它是否会直接导致 SE2700 取决于具体实现,不能只根据这个错误码下结论。

6. 保存并检查实际发出的 XML

在 connector 完成签名后、执行 HTTPS 请求前,保存一份脱敏后的原始 XML。主要检查:

  • XML 使用的命名空间是否正确。
  • Signature、SignedInfo、Reference 和 DigestValue 是否完整。
  • 被引用的节点 ID 是否存在且唯一。
  • canonicalization 和 signature method 是否符合当前协议要求。
  • XML 编码是否与声明一致。
  • 完成签名后,是否又格式化、缩进或重新构建了 DOM。
  • 签名后是否修改了 merchantReturnURL、时间戳或 Merchant ID。
  • 字段是否被转义了两次。
  • 是否存在 BOM、不可见字符或错误的换行处理。

一种典型的错误流程是:

$xml = buildRequest();
signXml($xml);
$xml->formatOutput = true; // 签名后再次序列化或修改
send($xml->saveXML());

更安全的流程是:

$xml = buildRequest();
signXml($xml);

$payload = $xml->saveXML();

// 从这里开始不要再修改或重新构建 XML。
send($payload);

XML Digital Signature 对节点内容、命名空间和规范化方式非常敏感。不要通过字符串拼接重写已经签名的 XML。

7. 确认 connector 与协议版本匹配

题目中的调用栈来自较早期的 PHP connector。如果将它迁移到不同的 PHP 或 OpenSSL 环境,可能出现以下兼容性问题:

  • connector 使用的签名算法已经不符合当前接口要求。
  • DOM 或 XML Security 依赖版本不兼容。
  • 接口不接受所用的私钥类型或长度。
  • 新版 OpenSSL 拒绝旧算法或旧密钥格式。
  • connector 生成的 canonicalization 结果发生了变化。

允许使用哪些签名算法、证书需要满足什么条件,以及应连接哪个 endpoint,都必须以当前 ING iDEAL 接入文档和商户环境配置为准,不能根据旧示例推测。排查时应先使用 ING 当前提供或认可的 connector/SDK,避免自行实现 XML Digital Signature。

merchantReturnURL 是否相关

http://www.04dertien.com 本身没有明显的 XML 特殊字符。即使 URL 带有查询参数,正确的 XML 序列化器也应该把 & 编码成 &amp;。

仍需确认签名前后的逻辑值相同,例如:

<merchantReturnURL>https://example.com/return?a=1&amp;b=2</merchantReturnURL>

不能先手工对 URL 进行 HTML 转义,再交给 DOM 二次转义,否则可能生成:

&amp;amp;

当前示例中的 URL 很简单,因此不应作为首要排查对象。

expose_php 是否会影响签名

不会。expose_php 主要控制是否发送类似下面的 HTTP 响应头:

X-Powered-By: PHP/...

它不参与:

  • merchant certificate 的选择;
  • private key 的加载;
  • XML canonicalization;
  • XML 摘要计算;
  • RSA 签名;
  • ING 对请求签名的验证。

关闭 expose_php 可以减少 PHP 版本信息暴露,但无法修复 SE2700。

最有效的解决办法

建议按以下顺序处理:

  1. 计算程序实际使用的 merchant certificate 的 SHA-256 指纹。
  2. 确认 ING 当前环境保存的是同一张证书。
  3. 验证该证书的公钥与 PHP 加载的 private key 一致。
  4. 确认 Merchant ID、证书、endpoint 和运行环境属于同一套配置。
  5. 捕获签名后实际发出的 XML,确认发送前没有再次修改。
  6. 使用符合当前协议要求的官方或受支持 connector 生成同一个 DirectoryRequest。
  7. 如果问题仍未解决,将 Merchant ID、环境、证书指纹、请求时间和 ING 返回的错误时间戳提供给 ING 技术支持,请其核对服务端验签所用的证书。不要发送私钥或私钥密码。

根据现有信息,最可能的原因不是 expose_php 或 merchantReturnURL,而是 ING 保存的 merchant certificate 与应用实际用于签名的 private key 不匹配,或者请求 XML 在签名后又发生了变化。

备注:内容仅供参考。