同一条支付成功通知,被处理了三次
支付平台的回调有一个基本约定:商户没有及时返回成功,平台就重试。某电商的订单回调接口只对 body 做签名,校验通过后按 order_id 发货、发积分、扣库存。上线两个月后做对账,发现一批订单重复发货。
查日志才看清过程:攻击者抓到了回调请求,把同一份 body 和签名原样重放了三次。服务端每次都验签通过,因为签名只覆盖 body,而 body 一个字都没变。第一次是正常通知,后两次是攻击者重放,系统无法区分。
更隐蔽的是,支付平台自己也会重试。网络抖动时同一条通知可能到达两三次,如果业务处理不是幂等的,正常重试同样会造成重复发货。签名解决“请求是不是伪造的”,重放保护解决“这条请求是不是已经处理过”,两者不能互相替代。
签名必须覆盖 body、时间戳和 nonce
只签 body 等于给攻击者一张永久有效的通行证。签名内容至少要包含时间戳,让旧请求在窗口外自动失效;再包含 nonce 或事件 ID,让窗口内的重复请求可以被识别。
| 字段 | 是否进签名 | 原因 | 漏签后的后果 |
|---|---|---|---|
| body | 是 | 防止金额、状态、订单号被替换 | 攻击者可篡改业务内容 |
| ts | 是 | 限定签名有效期 | 旧请求可以无限重放 |
| nonce / 事件 ID | 是 | 标识一次业务事件 | 无法区分正常重试与恶意重放 |
| 密钥版本 | 是 | 支持轮换并定位用哪把密钥 | 轮换期无法验证旧签名 |
| 算法标识 | 是 | 防止降级到更弱的算法 | 攻击者诱导使用不安全算法 |
规范串采用“时间戳十进制文本 + 点号 + 原始 body”。时间戳在前、body 在后,因为 body 长度可变,放在末尾不会产生拼接歧义。签名算法用 HMAC-SHA256,输出小写十六进制;比较时必须用常量时间函数,不能用 == 逐字节短路比较。
重放窗口要短,nonce 存储要能清理
时间窗口不是越长越好。窗口越长,攻击者拿到请求后可利用的时间越久;窗口太短,又会把正常的网络延迟和平台重试判成过期。常见做法是允许 30 到 300 秒的绝对偏差,并且窗口要覆盖支付平台的最长重试间隔。
nonce 的保留时间必须大于等于时间窗口。否则一条请求在签名仍然有效时,nonce 已经过期被清理,重放又能通过。保留时间也不宜过长,通常取窗口的两到三倍;再长只会让存储无限膨胀。
重复 nonce 的处理方式是“返回成功但不重复执行”。支付平台重试时希望看到 200,如果返回 409,它会继续重试;业务侧则必须直接确认,不再发货、不再加积分。nonce 的写入要做成原子操作,SETNX 加过期时间比“先查再写”可靠。
存储还要能清理。进程内 map 可以在每次写入时顺带删除过期项;Redis 用 SET key value EX ttl NX 让过期交给服务端。无论哪种方式,都要能发现时钟倒退:当前时间早于上一次接受时间时,直接报错,避免 nonce 被错误地判定为过期。
失败场景:只签 body 的回调
回到开头。攻击者不需要破解密钥,只需要在回调地址附近抓一次包。签名对 body 有效,时间戳没进签名,服务端也没有 nonce 记录,于是同一条请求可以重放任意次。
如果业务只按订单状态判断幂等,重复发货可能被挡住;但积分、优惠券、库存扣减往往不是同一个状态机。第一次回调加 100 积分,第二次再加 100,日志里看不出异常,因为每次请求都合法。对账时只能看到积分总量对不上,却很难定位到哪几条回调。
第三个变种是“验签通过就信任时间戳”。有些实现把 ts 放在 header 里但不参与签名,攻击者可以改 ts 绕过窗口检查。时间戳必须和 body 一起进签名,否则它只是装饰。
用 Sign、Verify 与 Accept 实现回调入口
下面的实现把签名、时间窗口和重放记录拆成三个函数。Sign 负责规范串,Verify 负责窗口与签名,Accept 负责 nonce 的一次性消费。调用方按“验签、验窗口、消费 nonce、处理业务”的顺序组合。
// file: internal/openplatform/callback.go
// tier: L1
// verify: go test ./internal/openplatform/
package openplatform
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
)
var (
// ErrBadSignature 表示签名不匹配。
ErrBadSignature = errors.New("openplatform: bad signature")
// ErrTimestampOutOfWindow 表示时间戳超出允许偏差。
ErrTimestampOutOfWindow = errors.New("openplatform: timestamp out of window")
// ErrInvalidTolerance 表示时间窗口被配置成负数。
ErrInvalidTolerance = errors.New("openplatform: invalid tolerance")
// ErrEmptyNonce 表示重放记录缺少 nonce。
ErrEmptyNonce = errors.New("openplatform: empty nonce")
// ErrInvalidNonce 表示 nonce 含有保留字符。
ErrInvalidNonce = errors.New("openplatform: invalid nonce")
// ErrNilReplay 表示没有提供重放记录。
ErrNilReplay = errors.New("openplatform: nil replay store")
// ErrInvalidTTL 表示 nonce 保留时间不是正数。
ErrInvalidTTL = errors.New("openplatform: invalid nonce ttl")
// ErrClockBackwards 表示当前时间早于上一次接受的时间。
ErrClockBackwards = errors.New("openplatform: clock moved backwards")
)
// clockKey 在 Nonces 里记录最近一次接受的 now,用于发现时钟倒退。
// 键里含有 NUL,正常 nonce 会被 Accept 拒绝,因此不会和业务 nonce 冲突。
const clockKey = "\x00clock"
// Sign 计算回调签名。被签名的内容由三部分组成:时间戳的十进制文本、
// 一个分隔点、原始 body。先放时间戳再放 body,长度可变的部分在最后,
// 不存在拼接歧义。返回值是 HMAC-SHA256 的小写十六进制。
func Sign(secret string, body []byte, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10)))
mac.Write([]byte("."))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// Verify 校验签名与时间窗口。tolerance 是允许的绝对偏差秒数。
// 先看时间戳再看签名:过期请求不必消耗一次 HMAC 计算,也不会因为
// 签名错误掩盖时钟问题。body 只读,不会被修改。
func Verify(secret string, body []byte, ts, now, tolerance int64, sig string) error {
if tolerance < 0 {
return ErrInvalidTolerance
}
diff := now - ts
if diff < 0 {
diff = -diff
}
if diff > tolerance {
return ErrTimestampOutOfWindow
}
want := Sign(secret, body, ts)
if !hmac.Equal([]byte(want), []byte(sig)) {
return ErrBadSignature
}
return nil
}
// Replay 记录最近接受过的 nonce 及首次出现时间。Nonces 由调用方持有,
// 可以放在进程内存,也可以序列化到 Redis;Accept 负责清理过期项。
type Replay struct {
Nonces map[string]int64
}
// Accept 判断 nonce 是否可以被接受。返回 true 表示首次出现,调用方应继续
// 处理业务;返回 false 表示 TTL 内重复,调用方应返回成功但不重复执行。
// nonce 为空、TTL 非正、store 为空或 now 小于上次接受时间都返回错误。
func Accept(r *Replay, nonce string, now, ttl int64) (bool, error) {
if r == nil {
return false, ErrNilReplay
}
if ttl <= 0 {
return false, ErrInvalidTTL
}
if strings.TrimSpace(nonce) == "" {
return false, ErrEmptyNonce
}
if strings.ContainsRune(nonce, '\x00') {
return false, ErrInvalidNonce
}
if r.Nonces == nil {
r.Nonces = make(map[string]int64)
}
if last := r.Nonces[clockKey]; last > now {
return false, ErrClockBackwards
}
for k, seen := range r.Nonces {
if k == clockKey {
continue
}
if now-seen >= ttl {
delete(r.Nonces, k)
}
}
r.Nonces[clockKey] = now
if _, seen := r.Nonces[nonce]; seen {
return false, nil
}
r.Nonces[nonce] = now
return true, nil
}
关键点:
- 规范串是
ts + "." + body,时间戳在前,body 原样在后。不要对 body 做 JSON 重排或 trim,否则签名和实际处理的内容可能不一致。 Sign返回小写十六进制。Verify用hmac.Equal比较,避免逐字节比较的时序差异。Verify先检查tolerance和偏差,再计算 HMAC。过期请求不消耗签名计算,错误码也能明确区分“过期”和“签名错”。Accept拒绝空 nonce、NUL 字符、非正 TTL 和 nil store。NUL 是内部时钟键的保留字符,必须挡在业务 nonce 之外。- 时钟键记录最近一次接受的
now。当前时间小于它时返回ErrClockBackwards,防止 nonce 被误判过期。 - 每次接受新 nonce 前清理过期项;重复 nonce 不延长 TTL,重放窗口始终从第一次出现开始算。
Sign和Verify都不修改 body,调用方可以把同一份字节继续交给业务解析。
测试:已知向量、时间边界与重放
测试要固定四件事:规范串不能变、时间边界不能漂移、重复 nonce 必须被识别、body 不能被修改。
// file: internal/openplatform/callback_test.go
// tier: L1
// verify: go test ./internal/openplatform/
package openplatform
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestSignKnownVector(t *testing.T) {
body := []byte(`{"order_id":"o-1","status":"paid"}`)
got := Sign("s3cr3t", body, 1761700000)
const want = "fb4b56ff21f5c2d6ccc5853592ea90647a1bf1189d9b5708e77c90d5388f1f12"
if got != want {
t.Fatalf("Sign = %s, want %s", got, want)
}
if got != strings.ToLower(got) {
t.Fatalf("signature %q is not lowercase", got)
}
if len(got) != 64 {
t.Fatalf("len = %d, want 64", len(got))
}
}
func TestSignCoversBodyAndTimestamp(t *testing.T) {
body := []byte("amount=100")
base := Sign("secret", body, 1000)
if base != Sign("secret", body, 1000) {
t.Fatal("same input produced different signatures")
}
if base == Sign("secret", body, 1001) {
t.Fatal("timestamp is not covered by the signature")
}
if base == Sign("secret", []byte("amount=101"), 1000) {
t.Fatal("body is not covered by the signature")
}
if base == Sign("other", body, 1000) {
t.Fatal("secret is not covered by the signature")
}
}
func TestVerify(t *testing.T) {
body := []byte("event=paid&order=o-1")
sig := Sign("secret", body, 1000)
if err := Verify("secret", body, 1000, 1029, 30, sig); err != nil {
t.Fatalf("fresh signature = %v, want nil", err)
}
if err := Verify("secret", body, 1000, 1030, 30, sig); err != nil {
t.Fatalf("boundary signature = %v, want nil", err)
}
if err := Verify("secret", body, 1000, 1031, 30, sig); !errors.Is(err, ErrTimestampOutOfWindow) {
t.Fatalf("stale ts = %v, want ErrTimestampOutOfWindow", err)
}
if err := Verify("secret", body, 1000, 969, 30, sig); !errors.Is(err, ErrTimestampOutOfWindow) {
t.Fatalf("future ts = %v, want ErrTimestampOutOfWindow", err)
}
if err := Verify("secret", body, 1000, 1000, 30, strings.ToUpper(sig)); !errors.Is(err, ErrBadSignature) {
t.Fatalf("uppercase sig = %v, want ErrBadSignature", err)
}
if err := Verify("secret", []byte("tampered"), 1000, 1000, 30, sig); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered body = %v, want ErrBadSignature", err)
}
if err := Verify("secret", body, 1000, 1000, -1, sig); !errors.Is(err, ErrInvalidTolerance) {
t.Fatalf("negative tolerance = %v, want ErrInvalidTolerance", err)
}
}
func TestVerifyDoesNotMutateBody(t *testing.T) {
body := []byte("a=1&b=2")
original := append([]byte(nil), body...)
sig := Sign("secret", body, 100)
if err := Verify("secret", body, 100, 100, 30, sig); err != nil {
t.Fatalf("Verify = %v, want nil", err)
}
if !bytes.Equal(body, original) {
t.Fatalf("body mutated to %q, want %q", body, original)
}
}
func TestAcceptReplayWindow(t *testing.T) {
var r Replay
ok, err := Accept(&r, "n-1", 100, 60)
if err != nil || !ok {
t.Fatalf("first Accept = %v, %v; want true, nil", ok, err)
}
ok, err = Accept(&r, "n-1", 130, 60)
if err != nil || ok {
t.Fatalf("duplicate Accept = %v, %v; want false, nil", ok, err)
}
ok, err = Accept(&r, "n-1", 160, 60)
if err != nil || !ok {
t.Fatalf("expired Accept = %v, %v; want true, nil", ok, err)
}
}
func TestAcceptCleansExpiredNonces(t *testing.T) {
var r Replay
if _, err := Accept(&r, "old", 100, 30); err != nil {
t.Fatalf("Accept old = %v", err)
}
if _, err := Accept(&r, "new", 200, 30); err != nil {
t.Fatalf("Accept new = %v", err)
}
if _, exists := r.Nonces["old"]; exists {
t.Fatalf("expired nonce old still present: %v", r.Nonces)
}
}
func TestAcceptRejectsBadInput(t *testing.T) {
var r Replay
if _, err := Accept(nil, "n", 100, 60); !errors.Is(err, ErrNilReplay) {
t.Fatalf("nil store = %v, want ErrNilReplay", err)
}
for _, ttl := range []int64{0, -1} {
if _, err := Accept(&r, "n", 100, ttl); !errors.Is(err, ErrInvalidTTL) {
t.Fatalf("ttl=%d = %v, want ErrInvalidTTL", ttl, err)
}
}
for _, nonce := range []string{"", " "} {
if _, err := Accept(&r, nonce, 100, 60); !errors.Is(err, ErrEmptyNonce) {
t.Fatalf("nonce=%q = %v, want ErrEmptyNonce", nonce, err)
}
}
if _, err := Accept(&r, "a\x00b", 100, 60); !errors.Is(err, ErrInvalidNonce) {
t.Fatalf("nul nonce = %v, want ErrInvalidNonce", err)
}
}
func TestAcceptDetectsBackwardsClock(t *testing.T) {
var r Replay
if _, err := Accept(&r, "n-1", 100, 60); err != nil {
t.Fatalf("first Accept = %v", err)
}
if _, err := Accept(&r, "n-2", 99, 60); !errors.Is(err, ErrClockBackwards) {
t.Fatalf("backwards clock = %v, want ErrClockBackwards", err)
}
}
TestSignKnownVector 用一个固定密钥、固定 body 和固定时间戳断言完整摘要,任何人改动规范串都会立刻失败;同时检查长度 64 和小写。TestSignCoversBodyAndTimestamp 分别改时间戳、body、密钥,确认三者都影响结果。
TestVerify 覆盖窗口内、正好等于 tolerance、过期、未来时间、大写签名、篡改 body 和负 tolerance。TestVerifyDoesNotMutateBody 在调用前后比较字节,防止以后为了“规范化”原地修改入参。
TestAcceptReplayWindow 验证首次接受、TTL 内重复返回 false、到期后重新接受。TestAcceptCleansExpiredNonces 确认旧 nonce 会被删除。TestAcceptRejectsBadInput 覆盖 nil store、非正 TTL、空 nonce 和 NUL。TestAcceptDetectsBackwardsClock 固定时钟倒退的报错语义。
运行命令与真实输出
cd .local/checks/webapp-lab && go test ./internal/openplatform/ -run 'TestSign|TestVerify|TestAccept' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestSignKnownVector
--- PASS: TestSignKnownVector (0.00s)
=== RUN TestSignCoversBodyAndTimestamp
--- PASS: TestSignCoversBodyAndTimestamp (0.00s)
=== RUN TestVerify
--- PASS: TestVerify (0.00s)
=== RUN TestVerifyDoesNotMutateBody
--- PASS: TestVerifyDoesNotMutateBody (0.00s)
=== RUN TestAcceptReplayWindow
--- PASS: TestAcceptReplayWindow (0.00s)
=== RUN TestAcceptCleansExpiredNonces
--- PASS: TestAcceptCleansExpiredNonces (0.00s)
=== RUN TestAcceptRejectsBadInput
--- PASS: TestAcceptRejectsBadInput (0.00s)
=== RUN TestAcceptDetectsBackwardsClock
--- PASS: TestAcceptDetectsBackwardsClock (0.00s)
PASS
ok webapp/internal/openplatform 0.228s
小结
回调签名至少要覆盖 body、时间戳和 nonce,窗口要短,nonce 的保留时间要大于窗口并且能自动清理。重复 nonce 返回成功但不重复执行,时钟倒退直接报错。Sign、Verify、Accept 分别负责规范串、窗口校验和一次性消费,边界由测试固定。下一节讨论第三方依赖故障:即使回调都处理正确,一个慢接口仍然可以把整条主链路拖垮。
阅读导航:上一节:10.1 应用、权限与配额 · 下一节:10.3 第三方故障的隔离 。
常见错误与边界
- 只对 body 签名。签名永远有效,攻击者可以无限重放,时间戳形同虚设。
- 时间戳放在 header 但不进签名。攻击者可以随意修改 ts,窗口检查被绕过。
- 对 body 做 JSON 重排后再验签。签名和业务解析看到的字节不一致,正常请求也会失败。
- 用
==比较签名。逐字节短路比较存在时序差异,应该使用常量时间比较。 - nonce TTL 小于时间窗口。签名还有效时 nonce 已被清理,重放重新通过。
- 重复 nonce 返回错误码。支付平台会继续重试,应该返回成功并跳过业务处理。
- 重复 nonce 时刷新 TTL。攻击者可以不断重放把 nonce 永久留住,窗口失去意义。
- 忽略时钟倒退。NTP 回拨或容器时间漂移会让 nonce 被误判过期,必须显式报错。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。