支付宝 QUICK_WAP_PAY 回调使用 RSA2 验签失败
结论
验签失败通常有两个原因:
-
异步回调要用支付宝公钥验签,不能使用应用公钥。
题目中的public_key和privete_key属于同一套应用密钥,只能用于验证应用私钥生成的签名,无法验证支付宝用其私钥生成的回调签名。 -
待验签内容要使用 URL 解码后的参数值。
不要直接对包含%3A、%40、%E7...的原始请求体排序和拼接。通过 Servlet 的request.getParameterMap()获取参数时,容器通常已经完成表单 URL 解码,无需再次解码。
正确的处理流程是:取得回调参数,确保参数只解码一次,再用支付宝 Java SDK 和支付宝公钥验签。
为什么当前方式无法通过
使用了错误的公钥
支付宝异步通知的签名方向是:
支付宝私钥签名 → 商户使用支付宝公钥验签
应用主动调用支付宝接口时,方向是:
应用私钥签名 → 支付宝使用应用公钥验签
这两种公钥的用途不同:
- 应用公钥:由应用私钥推导并上传到支付宝开放平台,供支付宝验证应用请求。
- 支付宝公钥:从支付宝开放平台获取,供应用验证支付宝响应和异步通知。
题目中的 public_key 和私钥属于同一套应用密钥,不能用于异步回调验签。
如果应用采用证书模式,还要使用与证书模式对应的 SDK 验签方式,不能与普通公钥模式混用。
拼接了 URL 编码后的参数值
题目中的原始数据包含:
gmt_create=2024-11-18+20%3A11%3A14
seller_email=123456665%40qq.com
subject=%E7%99%BD%E9%93%B6%E7%A4%BC%E5%8C%85
URL 解码后分别是:
gmt_create=2024-11-18 20:11:14
seller_email=123456665@qq.com
subject=白银礼包
支付宝 SDK 的常见用法,是将 request.getParameterMap() 取得的参数传给 AlipaySignature.rsaCheckV1()。Servlet 容器通常已经按照 application/x-www-form-urlencoded 规则完成解码,包括:
- 将
+转换为空格; - 将
%3A转换为:; - 将
%40转换为@; - 按指定字符集将
%E7...转换为中文。
因此,使用参数 Map 验签时,不要手工拼接原始的 URL 编码字符串,也不要进行二次 URL 解码。
推荐的 Java 验签方式
import com.alipay.api.AlipayApiException;
import com.alipay.api.internal.util.AlipaySignature;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
public boolean verifyAlipayNotify(HttpServletRequest request)
throws AlipayApiException {
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
String[] values = entry.getValue();
if (values == null || values.length == 0) {
params.put(entry.getKey(), "");
} else {
params.put(entry.getKey(), String.join(",", values));
}
}
String alipayPublicKey = "支付宝开放平台提供的支付宝公钥";
return AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
"UTF-8",
"RSA2"
);
}
这里传入完整的回调参数 Map,其中包括 sign 和 sign_type。SDK 会按验签规则处理签名字段,业务代码无需提前删除字段或手工拼接待验签字符串。
如果回调中的 charset 固定为:
charset=utf-8
验签时应使用:
"UTF-8"
验签字符集必须与通知使用的字符集一致。
如果只能取得原始请求体
如果框架没有提供解析后的参数 Map,只能取得下面这种原始请求体:
gmt_create=2024-11-18+20%3A11%3A14&seller_email=123456665%40qq.com&...
需要按照表单规则解析,并分别对参数名和参数值进行一次 URL 解码:
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public static Map<String, String> parseFormBody(String body) {
Map<String, String> params = new HashMap<>();
for (String pair : body.split("&")) {
int separator = pair.indexOf('=');
String rawName = separator >= 0 ? pair.substring(0, separator) : pair;
String rawValue = separator >= 0 ? pair.substring(separator + 1) : "";
String name = URLDecoder.decode(
rawName,
StandardCharsets.UTF_8
);
String value = URLDecoder.decode(
rawValue,
StandardCharsets.UTF_8
);
params.put(name, value);
}
return params;
}
解析完成后,直接把 Map 交给 SDK:
boolean verified = AlipaySignature.rsaCheckV1(
parseFormBody(rawBody),
alipayPublicKey,
"UTF-8",
"RSA2"
);
不要先对整个请求体执行一次 URLDecoder.decode(),再按 & 和 = 拆分。参数值本身可能包含这些特殊字符,整体解码后再拆分可能改变参数边界。
手工构造待验签字符串时的规则
如果需要排查 SDK 的输入,可以按照以下规则构造待验签字符串:
- 从回调参数中排除
sign和sign_type。 - 通常需要排除值为
null或空字符串的参数,具体规则应与所用 SDK 的实现一致。 - 按参数名的字典序升序排列。
- 使用 URL 解码后的参数值。
- 将各字段组成
key=value。 - 使用
&连接各字段。 - 按回调声明的字符集转换为字节。
- 使用
SHA256withRSA和支付宝公钥验证sign。
对应的部分内容应该是:
app_id=2021012345699159&auth_app_id=2021012345699159&buyer_id=2088812345678783&buyer_logon_id=176****8685&charset=utf-8&gmt_close=2024-12-03 20:11:24&gmt_create=2024-11-18 20:11:14&...
而不是:
gmt_close=2024-12-03+20%3A11%3A24
subject 也应该是解码后的中文,而不是 %E7%99%BD...。
手工验签的基本形式如下:
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public static boolean verifyRsa2(
String content,
String sign,
String alipayPublicKey
) throws Exception {
byte[] publicKeyBytes = Base64.getDecoder()
.decode(alipayPublicKey.replaceAll("\\s+", ""));
PublicKey publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(content.getBytes(StandardCharsets.UTF_8));
byte[] signatureBytes = Base64.getDecoder()
.decode(sign.replaceAll("\\s+", ""));
return verifier.verify(signatureBytes);
}
生产环境建议使用支付宝 SDK。手工实现更适合排查参数处理问题。
不要重新签名后比较字符串
验签并不是用应用私钥重新生成签名,再与回调中的 sign 比较。
正确的操作是:
支付宝公钥 + 待验签内容 + 回调 sign → 验证结果 true/false
回调签名由支付宝私钥生成,商户没有也不应持有这把私钥。使用应用私钥对同一字符串签名后得到的 Base64 结果,与支付宝回调中的 sign 没有可比性。
还需要检查的常见问题
sign 中的加号被转换成了空格
RSA 签名是 Base64 字符串,其中可能包含 +。如果自行解析请求体时处理错误,签名中的 + 可能被转换成空格,破坏 Base64 内容。
在正常的表单请求中,签名里的加号应该以 %2B 传输,并在表单解析后恢复为 +。不要使用简单的字符串替换或不完整的参数解析逻辑处理回调。
参数被业务代码修改
以下改动都会导致验签失败:
- 删除时间中的空格,或将其改回
+; - 将金额
30.00转换成30; - 重新编码中文;
- 对参数值执行
trim(); - 随意补充或删除空参数;
- 在 Map 转换过程中覆盖同名参数;
- 由网关、代理或框架修改请求体;
- 使用日志中经过脱敏的值验签。
题目中的:
buyer_logon_id=176****8685
sign=hgktcp32AZAYOCCU......
包含脱敏或截断内容,无法用于实际复现验签。排查时必须使用程序收到的完整原始参数,但不要把完整签名、账号信息或密钥写入公开日志。
应用、公钥和环境不匹配
需要确认以下信息属于同一个应用和同一个环境:
- 回调中的
app_id; - 当前应用配置;
- 支付宝公钥;
- 沙箱或生产环境;
- 普通公钥模式或证书模式;
sign_type=RSA2。
即使公钥类型正确,如果使用了另一个应用、另一个环境或历史配置中的支付宝公钥,验签仍会失败。
RSA2 密钥规格
题目展示的应用公钥看起来较短,可能是 1024 位。RSA2 配置通常要使用符合支付宝当前配置要求的 RSA 密钥规格,常见为 2048 位。具体要求应以当前开放平台的应用配置和所用 SDK 文档为准。
这不是本次验签失败的首要原因,但仍建议检查应用密钥是否与平台当前配置一致。
安全处理
题目中公开了完整的应用私钥。无论它是否仍用于生产环境,都不应继续将其视为安全密钥。建议立即:
- 在支付宝开放平台重新生成并配置应用密钥。
- 更新服务端保存的应用私钥。
- 检查旧私钥是否曾用于生产请求。
- 从代码仓库、日志、构建产物和配置文件中移除泄露内容。
- 使用密钥管理服务或受控环境变量保存新私钥。
综合判断,应先换成与当前应用、当前环境对应的支付宝公钥,再把 Servlet 已解码的参数 Map 原样传给 AlipaySignature.rsaCheckV1()。不要使用应用公钥,不要用 URL 编码后的原始字符串验签,也不要重新签名后比较 Base64 字符串。
备注:内容仅供参考。
