支付回调验证签名一直返回 false,官方 SDK 近期失效
结论
这种情况通常不是”官方 SDK 突然失效”,而是验签所用的原始数据、证书或密钥发生了变化。常见原因包括:
- 框架解析、转码或重新序列化了回调请求体,导致它不再是支付平台发送的原始字节。
- 支付平台轮换了平台证书,本地仍在使用旧证书。
- 商户私钥、平台公钥、API 密钥等用途不同的密钥被混用。
- 回调协议或签名算法已经升级,项目仍在使用旧版 SDK 或旧验签接口。
- 时间戳、随机串、签名值等请求头缺失,或者拼接顺序、换行符有误。
- 网关、代理或日志过滤器提前读取并修改了请求体。
- 实际收到的是测试请求、重复通知或其他类型的通知,并非成功支付回调。
排查时先不要猜测 SDK 是否有问题。保存一份完整的原始回调请求,再对照支付平台的当前文档逐项核对。
先确认验签需要哪些数据
各支付平台的验签规则不同,常见输入包括:
- HTTP 原始请求体
- 时间戳
- 随机串
- 签名值
- 证书序列号
- 签名算法
- 平台公钥或平台证书
验签必须使用回调请求中的原始值,不能先把 JSON 解析成对象再重新序列化。即使字段和值完全一致,只要字段顺序、空格、转义字符或换行符发生变化,验签就可能失败。
例如,下面两个 JSON 的业务含义相同,但对应的签名字节不同:
{"amount":100,"status":"SUCCESS"}
{
"status": "SUCCESS",
"amount": 100
}
如果支付平台签名的是第一段内容,使用第二段内容验签一定会返回 false。
按顺序排查
1. 保存原始请求信息
在 JSON 解析、参数绑定和业务处理之前,记录以下信息:
HTTP method
Request URI
Content-Type
Timestamp header
Nonce header
Signature header
Certificate serial number
Raw request body
在生产环境记录回调内容时应做好脱敏,不要输出私钥、API 密钥、完整银行卡号或其他敏感信息。
请求体如果已经被过滤器、拦截器或框架读取,控制器再次读取时可能得到空内容。此时应使用支持重复读取的请求包装器,或者由最前面的过滤器缓存原始字节。
2. 不要使用解析后的对象验签
错误做法:
PaymentNotification notification = objectMapper.readValue(body, PaymentNotification.class);
String rebuiltBody = objectMapper.writeValueAsString(notification);
// rebuiltBody 可能已经改变,不能保证与原始回调正文一致
boolean valid = verify(rebuiltBody, signature);
正确的处理方式是先保留原始正文并完成验签,成功后再进行解析:
byte[] rawBody = request.getInputStream().readAllBytes();
String body = new String(rawBody, StandardCharsets.UTF_8);
boolean valid = verifyWithOfficialSdk(
timestamp,
nonce,
body,
signature,
certificateSerialNumber
);
if (!valid) {
throw new SecurityException("Invalid payment callback signature");
}
PaymentNotification notification =
objectMapper.readValue(body, PaymentNotification.class);
这里的 verifyWithOfficialSdk 仅用于说明流程。实际方法名和参数顺序应以对应支付平台及所用 SDK 版本的文档为准。
3. 检查平台证书是否轮换
如果验签之前一直正常,最近才开始全部失败,应优先检查平台证书或平台公钥。
需要确认:
- 回调携带的证书序列号是否与本地证书匹配。
- 本地证书是否已过期。
- 平台是否发布了新证书。
- 自动下载或更新证书的任务是否执行失败。
- 多实例部署中是否只有部分实例更新了证书。
- 容器镜像、配置中心或密钥管理服务中是否仍保存着旧证书。
- 灰度环境和正式环境是否加载了不同证书。
不要只凭文件名判断证书,可以通过以下命令查看证书序列号和有效期:
openssl x509 -in platform_cert.pem -noout -serial -dates -subject
如果平台允许多张证书同时有效,应根据回调中的证书序列号选择对应证书,不能始终使用固定的一张。
4. 确认没有混用密钥
支付系统通常会同时使用多种凭据:
- 商户私钥:通常用于给商户请求签名。
- 商户公钥或商户证书:通常供平台验证商户签名。
- 平台公钥或平台证书:通常供商户验证平台回调。
- API 密钥:可能用于报文解密或派生密钥,不一定用于验签。
- Webhook Secret:部分平台使用 HMAC 验证回调。
验签时必须使用平台指定的密钥。如果把商户公钥、商户证书或 API 密钥传给需要平台公钥的接口,通常只会得到 false,未必会抛出明显异常。
还要检查测试环境与生产环境是否发生混用。测试环境的回调不能使用生产环境的平台证书验证,反过来也一样。
5. 检查签名串的拼接方式
如果 SDK 要求分别传入时间戳、随机串和正文,就应原样传递,不要先自行拼接,再把结果交给 SDK。
如果协议要求手动构造待验签字符串,必须严格按照平台文档处理。例如,某些协议可能使用以下格式:
timestamp + "\n" + nonce + "\n" + body + "\n"
常见错误包括:
- 漏掉最后一个
\n - 使用了
\r\n - 参数顺序错误
- 对正文调用了
trim() - 对签名字符串进行了 URL 解码
- Base64 解码次数不正确
- 时间戳转成数字后格式发生变化
- 请求头值含有未处理的前后空白
上面的拼接格式只是常见示例,并非所有支付平台都采用这一规则。具体格式应以目标平台的当前文档为准。
6. 检查 SDK、协议和运行环境
需要确认项目实际加载的 SDK 版本,不能只看构建文件中的版本声明。Java 项目可以检查依赖树:
mvn dependency:tree
或:
./gradlew dependencies
重点检查以下问题:
- 项目是否同时引入了新旧两个 SDK。
- 其他依赖是否传递引入了旧版本。
- 回调接口是否已经从旧协议迁移到新协议。
- SDK 是否改用了新的证书管理方式。
- JDK、OpenSSL 或加密 Provider 升级后,算法支持是否发生变化。
- 测试配置是否覆盖了生产环境配置。
- 配置文件中的 PEM 内容是否因换行、转义或环境变量注入而损坏。
不要在没有依据时直接升级到最新版。先查看当前版本对应的更新说明和迁移文档,再在测试环境中使用真实保存的回调样本做回归验证。
手动验签可用于定位问题
如果平台使用 SHA256withRSA,可以绕过 SDK 做一次对照验证,从而判断问题出在数据或证书,还是 SDK 的调用方式。
import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
public static boolean verifySha256WithRsa(
PublicKey platformPublicKey,
String message,
String base64Signature) throws Exception {
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(platformPublicKey);
verifier.update(message.getBytes(StandardCharsets.UTF_8));
byte[] signatureBytes = Base64.getDecoder().decode(base64Signature);
return verifier.verify(signatureBytes);
}
调用时,message 必须完全按照支付平台规定的格式构造:
String message =
timestamp + "\n" +
nonce + "\n" +
rawBody + "\n";
boolean valid = verifySha256WithRsa(
platformPublicKey,
message,
signatureHeader
);
这段代码只适用于明确采用 RSA 和 SHA256withRSA 的协议。如果平台使用 RSA-PSS、ECDSA、HMAC 或其他算法,不能直接套用。
可以根据结果进一步判断:
- SDK 和手动验签都失败:优先检查原始正文、证书、签名串和环境。
- 手动验签成功,但 SDK 失败:检查 SDK 版本、参数顺序、证书加载方式和接口选择。
- 同一份请求有时成功、有时失败:检查负载均衡后各实例的配置,以及证书是否完成同步。
- 历史请求成功,新请求失败:重点检查证书轮换或回调协议变化。
还要检查代理和中间件
如果回调会经过 CDN、WAF、API 网关或反向代理,应确认这些组件没有执行以下操作:
- 解压或重新压缩请求体
- 改写字符编码
- 修改请求头名称或值
- 删除签名相关请求头
- 将请求体转换成表单参数
- 重新生成 JSON
- 限制请求体长度
- 提前消费请求流
可以分别在网关入口和应用入口记录原始正文的 SHA-256 摘要。如果两个摘要不同,说明请求在传输链路中发生了变化:
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawBody);
String bodyHash = HexFormat.of().formatHex(hash);
与完整记录支付回调正文相比,记录摘要更安全,也便于比较不同节点收到的数据是否一致。
推荐的处理流程
回调入口应按以下顺序处理:
读取并缓存原始请求体
↓
读取签名相关请求头
↓
按证书序列号选择平台证书
↓
调用官方 SDK 验签
↓
验签成功后解密或解析业务数据
↓
校验商户号、订单号、金额和状态
↓
执行幂等业务处理
↓
返回平台要求的成功响应
验签成功不代表回调可以直接入账。系统还需要校验商户号、订单金额、币种和订单状态等业务字段,并使用支付单号或平台通知编号进行幂等控制。
如果经过上述排查仍无法定位问题,可以准备一份脱敏后的失败样本,内容包括原始请求体、签名相关请求头、证书序列号、SDK 名称及实际版本、运行时版本和手动验签结果,然后提交给支付平台技术支持。不要提供任何私钥或 API 密钥。
备注:内容仅供参考。
