编程 支付回调 / Webhook 验签器(Verifier)的正确实现:HMAC、时间窗、防重放与幂等

2026-09-02 00:04:42

支付回调 / Webhook 验签器(Verifier)的正确实现

先讲一次事故

线上出现过一个真实案例:一份合法的「支付成功」回调请求被截获后复制,随后被反复发给回调接口。HMAC 验签每次都通过——签名算法没有任何问题——但业务侧因此重复发货、重复入账。

问题不在签名,而在我们对「验签通过」这件事的理解:

HMAC 只能证明「内容确实来自持有密钥的合作方」,证明不了「这条消息之前从没被服务端处理过」。

验签器只解决了一半的问题。另一半叫幂等


验签器需要绑定哪几样东西

一个合格的验签器,至少要同时校验:

  • 原始 body——签名基于未经过任何解析/重编码的原始字节计算。
  • 时间戳——拦截旧的重放请求。
  • 事件 ID / nonce——拦截几秒窗口内的连续重放。
  • HMAC——保证内容完整性和来源可信。

四者缺一不可。缺时间戳和 nonce,验签通过 = 内容可信但可能已处理过;缺原始 body 的约束,验签在 JSON 字段顺序变化时直接失败或被绕过。


四个关键坑和可复现的 Go 代码

坑一:先解析 JSON 再验签

后果:请求体被解码再重新编码后,键值顺序、空格、转义可能与签名时不一致,直接验签失败。更糟的是,某些实现用重新编码后的 body 去验签,等于是验了一个攻击者可控的对象。

正确做法:签名计算的输入必须是 r.Body 的原始字节。先验签,验签通过后再做 JSON 解析。

// 只读原始 body,绝不复用
func readOriginalBody(r *http.Request) ([]byte, error) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return nil, err
    }
    // 如果后续还需要读 r.Body,写回;否则直接关闭
    r.Body.Close()
    return body, nil
}

拿到原始 body 之后,再进入验签流程。验签通过,才允许 json.Unmarshal

坑二:用逐字节比较而不是常量时间比较

后果string(signA) == string(signB) 在遇到第一个不匹配字节时就会短路返回。在多次请求的时序统计下,攻击者可以逐字节推断出合法签名。

正确做法:用 crypto/hmac 包自带的 hmac.Equal

import "crypto/hmac"

func validMAC(msg, receivedMAC, key []byte) bool {
    expectedMAC := hmac.New(sha256.New, key)
    expectedMAC.Write(msg)
    return hmac.Equal(expectedMAC.Sum(nil), receivedMAC)
}

hmac.Equal 内部走 crypto/subtle 的常量时间比较,零成本,没有理由不用。

坑三:时间窗只校验「够不够旧」,挡不住秒级重放

后果:一个刚被截获的请求完全落在时间窗内,可以直接原样重放,验签照样通过。

正确做法:时间窗过滤掉「很旧」的请求,同时引入 nonce / event_id 去重。把事件 ID 写入带 TTL 的存储,首次写入成功、重复写入失败。

// 假设 redisClient 已注入

// eventID 去重:TTL 建议取时间窗的两倍以上(比如 10 分钟)
func deduplicate(ctx context.Context, eventID string) error {
    ok, err := redisClient.SetNX(ctx, "webhook:dedup:"+eventID, "1", 10*time.Minute).Result()
    if err != nil {
        // 存储故障时宁可直接拒绝,也不要悄悄放行
        // 否则重放保护在依赖故障时静默失效
        return fmt.Errorf("dedup store unavailable: %w", err)
    }
    if !ok {
        return ErrDuplicateEvent
    }
    return nil
}

注意SetNX 写失败时不要放行。这个错误路径如果不处理,等于重放保护在有故障的时候失效,而业务侧并不知道。

坑四:nonce 去重 ≠ 业务幂等

后果:发送方网络重试会生成新的 nonce 和签名;人工补发也会有新的事件 ID。这些请求验签都合法、去重都通过,但对应的还是同一笔订单。

正确做法:nonce 只解决「同一份请求被复制重放」,业务幂等要靠数据库唯一键和状态机兜底。

// 数据库唯一键(MySQL 示例语义)
// UNIQUE KEY uk_order_event (order_id, event_type)

// 状态机:pending -> paid
// 只在当前状态为 pending 时允许更新为 paid
result, err := db.ExecContext(ctx,
    `UPDATE orders SET status='paid', paid_at=NOW()
     WHERE order_id=? AND status='pending'`, orderID,
)
if err != nil && isDuplicateKeyError(err) {
    // 同一订单同一事件已处理过,幂等返回成功
    return nil
}
if rowsAffected == 0 {
    // 状态已不是 pending,说明之前已收到过该事件
}

这个状态机是幂等的最后一道防线,它不需要依赖任何外部系统,只靠数据库自身的一致性。


微信支付 APIv3 的验签差异

微信支付 APIv3 用的不是 HMAC,而是 SHA256withRSA。几个关键点:

验签所需字段

请求头:

  • Wechatpay-Signature——Base64 编码的 RSA 签名
  • Wechatpay-Serial——平台证书序列号,必须与本地持有的平台证书序列号一致
  • Wechatpay-Timestamp
  • Wechatpay-Nonce

验签串的构造

微信的验签串是一个三行字符串,注意每行以 \n 结尾,最后一行也有:

{timestamp}\n
{nonce}\n
{body}\n

如果 body 为空(比如部分 GET 或查询类回调),构造出来的验签串以最后一个 \n 结尾,中间没有 body 内容。

Go 实现示例:

func wechatSignStr(timestamp, nonce string, body []byte) []byte {
    // 注意:最后一行也必须带 \n
    s := fmt.Sprintf("%s\n%s\n%s\n", timestamp, nonce, string(body))
    return []byte(s)
}

验签流程

import (
    "crypto"
    "crypto/rsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "fmt"
)

func verifyWechatSignature(serial, timestamp, nonce string, body []byte, b64signature string) error {
    // 1. 序列号必须与本地平台证书一致,不一致说明证书被轮换,拒绝
    if serial != wechatPlatformCert.SerialNumber.String() {
        return fmt.Errorf("wechat platform cert serial mismatch: got %s", serial)
    }

    // 2. 时间窗建议 5 分钟,双向
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("bad timestamp: %w", err)
    }
    if abs(time.Now().Unix()-ts) > 300 {
        return fmt.Errorf("wechat callback timestamp out of window: %d", ts)
    }

    // 3. SHA256withRSA
    //    signStr = timestamp + "\n" + nonce + "\n" + body + "\n"
    signStr := fmt.Sprintf("%s\n%s\n%s\n", timestamp, nonce, string(body))
    digest := sha256.Sum256([]byte(signStr))

    cert := wechatPlatformCert
    pubKey, ok := cert.PublicKey.(*rsa.PublicKey)
    if !ok {
        return fmt.Errorf("wechat platform cert is not RSA")
    }

    sig, err := base64.StdEncoding.DecodeString(b64signature)
    if err != nil {
        return fmt.Errorf("bad base64 signature: %w", err)
    }

    if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sig); err != nil {
        return fmt.Errorf("wechat signature verify failed: %w", err)
    }
    return nil
}

探测流量:WECHATPAY/SIGNTEST/

微信支付会向商户回调地址发送错误签名的探测请求,路径带 WECHATPAY/SIGNTEST/ 前缀。它故意验签失败,但这是平台方的主动探测,正常走验签流程、验签失败按失败处理即可,不要因此拉黑对方 IP。

验签失败返回 4xx / 5xx

验签失败时不要返回 200 OK。微信支付平台收到非 2xx 响应会触发重发机制,这是它自带的可靠性保障。你吞掉错误码返回 200,等于让平台以为你处理成功了,事件就丢了。


什么情况下别这么做

明确几条边界,看着像「安全加固」实际上会引入新问题:

  • 不要让回调接口依赖 Session / Cookie 登录态。回调是系统间通信,不是浏览器会话。带上这些依赖,平台重试时你的接口可能因为会话过期直接挂掉。
  • 不要用 IP 白名单作为唯一防线。回调接口本质是外部系统对内部数据库的写入口,白名单能挡一部分扫描流量,但挡不住来源 IP 被伪造或可信网络里的横向移动。「验签 + 防重放 + 原子更新」三位一体,缺一个都不行。
  • 验签通过 ≠ 已处理。不要在验签通过后同步执行完整业务逻辑。正确顺序是:验签 → 去重 → 返回 200 → 消息入队 → 异步处理。如果你在回调里同步做完整业务操作,平台等不到响应会重试,重试时你的业务逻辑可能已经执行了,这时候再靠数据库状态机兜底,代码路径会变得很绕。
  • 时间窗不是越大越好。窗口太大会给被截获的请求留足重放时间;太小会因为时钟漂移误拦正常请求。双向 5 分钟是一个合理的默认值,但生产环境要确保 NTP 同步时钟,否则时间窗本身形同虚设。
  • 不要在接口里落日志时记录完整回调正文。记录 event_id、订单号、验签结果、时间偏差就够。共享密钥和完整 body 落到日志里,一旦日志泄露等于密钥泄露。

审计与监控

记录以下字段,用于事后排查和线上告警:

  • 验签结果(通过/失败)
  • 时间偏差值(用于观察平台时钟与本地时钟的漂移趋势)
  • nonce / event_id 去重结果(已处理/首次处理/存储故障)
  • body 哈希(做完整性审计,不落原文)

对以下异常配置告警:

  • 连续验签失败
  • nonce 重复
  • 时间偏差异常增大

总结一句话

验签器验证的是「这条消息是谁发的、内容有没有被改过」;幂等验证的是「这条消息对应的业务有没有被执行过」。 Go 里实现前者用 hmac.Equal + 原始 body + 时间窗,实现后者用 Redis SETNX + TTL + 数据库唯一键 + 状态机。两边都做到,回调接口才真正可靠。

复制全文 生成海报 Verifier Webhook 支付回调 幂等 防重放 Go

推荐文章

程序员茄子在线接单