PAYATHON 2026

支付宝 QUICK_WAP_PAY 回调使用 RSA2 验签失败

支付老李

结论

验签失败通常有两个原因:

  1. 异步回调要用支付宝公钥验签,不能使用应用公钥。
    题目中的 public_key 和 privete_key 属于同一套应用密钥,只能用于验证应用私钥生成的签名,无法验证支付宝用其私钥生成的回调签名。

  2. 待验签内容要使用 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 的输入,可以按照以下规则构造待验签字符串:

  1. 从回调参数中排除 sign 和 sign_type。
  2. 通常需要排除值为 null 或空字符串的参数,具体规则应与所用 SDK 的实现一致。
  3. 按参数名的字典序升序排列。
  4. 使用 URL 解码后的参数值。
  5. 将各字段组成 key=value。
  6. 使用 & 连接各字段。
  7. 按回调声明的字符集转换为字节。
  8. 使用 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 文档为准。

这不是本次验签失败的首要原因,但仍建议检查应用密钥是否与平台当前配置一致。

安全处理

题目中公开了完整的应用私钥。无论它是否仍用于生产环境,都不应继续将其视为安全密钥。建议立即:

  1. 在支付宝开放平台重新生成并配置应用密钥。
  2. 更新服务端保存的应用私钥。
  3. 检查旧私钥是否曾用于生产请求。
  4. 从代码仓库、日志、构建产物和配置文件中移除泄露内容。
  5. 使用密钥管理服务或受控环境变量保存新私钥。

综合判断,应先换成与当前应用、当前环境对应的支付宝公钥,再把 Servlet 已解码的参数 Map 原样传给 AlipaySignature.rsaCheckV1()。不要使用应用公钥,不要用 URL 编码后的原始字符串验签,也不要重新签名后比较 Base64 字符串。

备注:内容仅供参考。