《互联网产品的服务端架构·交易、增长与平台化》10.2 回调、签名与重放

支付回调只签了 body、没签时间戳,攻击者把同一条成功通知重放三次,订单被重复发货;文章说明签名必须覆盖哪些字段、重放窗口为什么越短越好、nonce 存储怎样清理,并给出 Sign、Verify、Replay 的 Go 实现、已知向量测试与真实运行输出。

同一条支付成功通知,被处理了三次

支付平台的回调有一个基本约定:商户没有及时返回成功,平台就重试。某电商的订单回调接口只对 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 被误判过期,必须显式报错。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查