Skip to content

回调验签模块设计 ​

回调入口无 JWT、网关白名单放行——验签是唯一防线。三类攻击各有其墙:伪造靠验签、重放靠时间窗+nonce、乱序靠业务状态机。本篇把 star-site 支付模块(mock/支付宝/微信三渠道)的真实代码整合为模块级视图。视图包括 SignVerifier 策略抽象、时序安全比较、原始字节流的坑、重放防护三层。单点的深度拆解见支付回调的HMAC验签与幂等设计,与对账的关系见对账差错处理模块设计。

模块视图:端口 + 策略 + 幂等入账 ​

渠道服务器 ──POST /api/payment/notify/{channel}──▶ 回调入口(白名单,无 JWT)
        headers(键小写归一) + 原始字节流 rawBody          │
                                                          ▼
                                          ┌───────────────────────────────┐
                                          │  PaymentChannel(端口,六边形)  │
                                          │  .verifyAndParse(headers,body) │
                                          └───────────┬───────────────────┘
                                    channelRegistry.get(channelType) 策略分发
                     ┌────────────────────┼────────────────────┐
                     ▼                    ▼                    ▼
             MockPaymentChannel    AlipayPaymentChannel   WechatPayChannel
             HMAC-SHA256(共享密钥)  RSA2(公钥验 form)       v3 四件套+平台证书
                     │ 验签失败 → 抛 BizException(401),不给渠道任何成功应答
                     ▼
             CallbackResult(orderNo, channelTradeNo, paidAmount, success)
                     │
                     ▼
             业务幂等:金额核对(compareTo!=0 拒绝) → casStatus(CREATED→SUCCESS)
                     │                        → 事务消息入账事件 → ACK
                     └─ moved=false → 幂等应答 ACK(重放/迟到/乱序同一出口)

star-site 的落法是把验签并入 PaymentChannel 端口(verifyAndParse 是端口的一个方法),渠道适配器即策略。模块级等价的独立抽象是:

java
/** 渠道回调验签策略。实现:MockHmac / AlipayRsa2 / WechatV3(Spring 注入成 Map<channel, verifier>) */
public interface SignVerifier {
    String channel();
    /** 验签 + 解析一体:失败抛 BizException(401);成功返回解析结果(未验签的字节一个都不喂业务) */
    CallbackResult verifyAndParse(Map<String, String> headers, String rawBody);
}

策略模式的收益不是"看起来优雅",而是新增渠道零侵入。接银联/微信插件化时只加一个适配器类,入口 Controller、幂等入账、ACK 协议一行不改。

双实现:HMAC 与 RSA2(star-site 真实代码) ​

HMAC-SHA256(mock 沙箱渠道,共享对称密钥):

java
// MockPaymentChannel:联调/演示零外部依赖,完整走"预下单凭证→签名回调→验签入账"闭环
public String sign(String orderNo) {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    return HexFormat.of().formatHex(mac.doFinal(orderNo.getBytes(StandardCharsets.UTF_8)));
}
// 验签侧比对摘要(沙箱简化,生产见下节时序安全比较)
if (!sign(orderNo).equals(sign)) { throw new BizException(401, "mock 回调验签失败"); }

RSA2(支付宝渠道,非对称密钥对)——回调是 form-urlencoded 报文,先自行解析参数表,SDK 验签时剔除 sign/sign_type:

java
// AlipayPaymentChannel.verifyAndParse
Map<String, String> params = parseForm(rawBody);            // 自己拆原始字节,见下节的坑
if (!AlipaySignature.rsaCheckV1(params, alipayPublicKey, CHARSET, SIGN_TYPE)) {
    throw new BizException(401, "支付宝回调验签失败");         // rsaCheckV1 内部剔除 sign/sign_type 后验签
}

微信 v3 是第三种形态:Wechatpay-Serial/Nonce/Signature/Timestamp 四件套请求头 + 平台证书(RSAAutoCertificateConfig 自动下载轮换)验 RSA-SHA256,再 AES-GCM 解密取业务明文。notificationParser.parse 内部顺序固定为验签→解密→解析 JSON。顺序反了就是拿未验签数据喂业务。

选型的本质是密钥分发模型。HMAC 双方共享同一密钥,泄密面是双份,适合双方都是自己的系统(mock/内部回调)。RSA2 我方只持对方公钥,对方私钥永不落地,适合支付宝/微信这种互不信任的外部渠道。没有谁更"高级",只有信任模型匹配不匹配。

时序安全比较:MessageDigest.isEqual ​

验签的最后一公里是"比较两个摘要",这里埋着一条侧信道:

java
// ✗ String.equals / Arrays.equals:首个不匹配字节提前返回。
//   攻击者逐字节试探:改签名字段第 1 字节测 256 个值,耗时更长的那个 = 前缀匹配成功,
//   256 × 签名长度次请求即可伪造一个合法签名(时序侧信道爆破)。
//
// ✓ 常量时间比较:内部把两个数组逐字节异或并累加到底再判零,
//   比较耗时与"在第几个字节分岔"无关,侧信道被焊死。
byte[] expected = HexFormat.of().parseHex(hmacHex(secret, payload));
byte[] actual   = HexFormat.of().parseHex(clientSignHex);
if (!MessageDigest.isEqual(expected, actual)) {
    throw new BizException(401, "验签失败");
}

诚实标注:上面 mock 渠道用的 sign(orderNo).equals(sign) 是沙箱简化。密钥是 dev-only 默认值、无真实资产,攻击面不成立。生产渠道(微信 SDK 内部、支付宝 SDK 的 RSA 验签)已用常量时间实现。判断一个"不严谨"是不是 bug 的标准不是写法本身,而是它守护的密钥是否是真实资产。这比机械套用最佳实践更接近安全工程的本义。

原始字节流读取的坑 ​

验签对象是"渠道发来的字节",任何一次转手都可能改变它。四个实打实的坑:

  1. Servlet InputStream 只能读一次。回调 Controller 读了 body,后续过滤器/日志切面再读就是空流。入口处一次读出 byte[],或用 ContentCachingRequestWrapper 缓存一份。同时要限制只对 notify 路径包装(大报文接口全量缓存会吃内存)。
  2. getParameter 会把流当 form 消费并 URL decode。之后想"重新 encode 拼回原串"再验签,空格变 + 还是 %20、*/~ 的编码差异。拼出来的串与渠道签名所覆盖的原文必然不一致。支付宝 form 回调必须像上面 parseForm(rawBody) 那样自己拆原始字节,绝不经 getParameter 转手。
  3. headers 键大小写不敏感但 Map 敏感。Wechatpay-Serial 与 wechatpay-serial 是两个 key,网关/HTTP 客户端都可能改写大小写。四件套取值前统一 toLowerCase 归一。
  4. 不要重新序列化 JSON 再验签。字段重排、空格增减、Unicode 转义形式变化都会改变字节,签名随之失效。验签用原始字节,业务 JSON 解析发生在验签之后。

一句话铁律:验签的输入是原始字节,业务解析是验签的后继动作——顺序反了,验签就退化成仪式。

重放防护:时间窗 + nonce 去重表 ​

验签通过只能证明"报文是渠道发的",不能证明"第一次收到"——重放的报文签名完全合法。防重放是验签之外的独立一层:

sql
CREATE TABLE callback_nonce (              -- 窗内重放去重表
  channel   VARCHAR(16) NOT NULL,
  nonce     VARCHAR(64) NOT NULL,        -- 微信: wechatpay-nonce;自建渠道: 消息 id/流水号
  expire_at DATETIME    NOT NULL,
  UNIQUE KEY uk_channel_nonce (channel, nonce)
);
-- 验签通过后先占坑:INSERT 撞唯一键 = 窗内重放,直接拒绝(不进业务)
-- 定时任务清理 expire_at < NOW(),表尺寸 = 时间窗 × 回调峰值 QPS,恒定可控
java
// 入口两级快速拒绝(免进业务逻辑):
// ① 时间窗:|now - headers.timestamp| > 300s → 401(微信 v3 四件套自带 timestamp)
// ② nonce:INSERT callback_nonce 撞 uk → 401
// 最后防线仍是业务状态机:CAS WHERE status=CREATED,重放撞不上谓词,幂等应答 ACK

三层的分工:时间窗挡"旧报文"(免查库的 O(1) 拒绝,窗口外的重放根本不落库)。nonce 表挡"窗内重放"(同样不进业务)。业务 CAS 兜一切(前两层被绕过/绕漏时的正确性保底,见幂等与对账-资金安全底座三层幂等)。只靠 CAS 时正确性仍然成立,但每次重放都要走完"查单+金额核对+UPDATE"全链路。渠道重试风暴(超时未 ACK 时指数重发)会把幂等层当防火墙烧。入口层防重放是性能与隔离,CAS 是正确性,两层回答的是不同的问题。

追问链 ​

Q1:验签都通过了,为什么还要 nonce 和时间窗? 因为签名回答的是"谁写的",不回答"第几次读"。攻击者截获一条合法回调原样重发,签名逐字节成立。防它的只能是"这条消息只允许生效一次"的机制:时间窗让旧报文过期,nonce 让窗内重复失效。验签与防重放是正交的两堵墙,合并成一个"验签就安全"的心智模型是回调安全最常见的错觉。

Q2:有了业务 CAS 幂等,入口的 nonce 表是不是过度设计? 看副作用半径。支付回调的副作用是"状态迁移+事件发布",CAS 能兜。但回调流量里还有通知类、查询应答类报文没有天然的"状态谓词"可撞。且重放洪峰本身是流量攻击面。

nonce 表用一张恒定小表把重放挡在业务库之外——它买的是隔离,不是正确性。只做小系统,CAS 单层够;做渠道网关,两层都要。

Q3:为什么 ContentCachingRequestWrapper 有内存代价还是必须用? 因为流只能读一次是 Servlet 规范级约束,而"验签字节 = 业务解析字节"必须是同一份。代价可控的条件是:只对 notify 白名单路径包装(回调体 KB 级),绝不对文件上传类接口全量缓存。任何全局 Wrapper 都是错的——正确的做法是精确圈定包装范围。

Q4:RSA2 和 HMAC 到底怎么选? 按密钥分发模型选:双方互信且都是自己的系统(内部服务回调、mock 沙箱)→ HMAC,简单高效。对方是不可信外部机构(支付宝/微信/银行)→ RSA2 非对称,我方只存对方公钥,对方私钥不落地。单点泄密面减半。反着选的两种事故:对外部渠道用 HMAC 等于把主密钥发给对方保管;内部服务间用 RSA2 徒增证书运维。

Q5:微信平台证书轮换的瞬间,验签会不会断? 按 Wechatpay-Serial 指定的证书序列号选验签证书,新旧证书在窗口期内同时可用。RSAAutoCertificateConfig 自动下载轮换就是为了这个。支付宝的公钥是单值配置,轮换要自己实现"双公钥灰度"(新签名用新钥验,验不过再试旧钥)。过渡期后摘除。密钥轮换的可用性设计在验签器选钥逻辑里,不在密钥本身。

Q6:mock 渠道用 equals 比较签名,是生产 bug 吗? 判定标准是密钥是否真实资产。mock 用的是 dev-only 默认密钥、沙箱流量,equals 的时序侧信道不构成攻击面,属于被注释明示的简化。同一写法出现在生产渠道就是 P0。安全评审的对象是"威胁模型 × 实现",不是逐行比对最佳实践清单。这也是面试里区分"背规则"和"懂安全"的问题。

面试官视角小结 ​

回调验签的面试从"验签怎么做的"开始,区分度在四层:

  • 第一层:能不能画出"验签→防重放→幂等入账"的分层防线,并说清每层各挡哪种攻击(伪造/重放/乱序)。只答"调 SDK 验签"的直接出局。
  • 第二层:原始字节流的实战痕迹——InputStream 只能读一次、getParameter 消费流、header 大小写。踩过坑的人会主动讲"验签用原始字节、解析在后"这条铁律,没踩过的背不出来。
  • 第三层:MessageDigest.isEqual 不只是 API 名。要能讲清时序侧信道的爆破原理和"简化是否是 bug 取决于威胁模型"的判断力。
  • 第四层:防重放三层(时间窗/nonce/CAS)的分工论证——每层回答的问题不同(性能隔离 vs 正确性兜底)。说不出"为什么有了 CAS 还要 nonce"的候选人,缺的是分层防御的成本收益观。

这篇素材全部来自 star-site 支付模块真实代码。面试时能引用自己仓库里的 casStatus 与三渠道适配器,比任何八股都可信。

2026-10 新增(金融信贷域 · 模块设计层)。