回调不是“支付成功通知”
支付渠道发来的 HTTP 请求,本质上是外部输入。它可能来自渠道,也可能来自任何知道回调地址的人;即使签名正确,同一个请求也可能被重放十次。把回调当成“渠道说成功了”直接改订单状态,等于把订单状态机交给公网。arch-lab 的支付回调只做四件事:验证请求确实由持有密钥的一方发出、确认它在时间窗内、确认这个 nonce 没用过、确认金额与本地订单一致。四件事都通过,才把 Payment 置为成功。渠道返回的订单号、金额、状态都只是“声明”,不是事实;事实以本地订单和验签结果为准。本节不写密钥管理产品综述。密钥从哪来、存在哪、怎么审计,各家的 KMS 都能做;架构上真正容易错的是密钥版本怎么选、旧密钥什么时候能删、验签和重放检查的先后顺序。这些用一段可运行的代码和失败用例说清楚。
信任边界上的四个事实
| 事实 | 来源 | 是否可信 | 校验方式 |
|---|---|---|---|
| 请求由渠道发出 | 请求体与签名 | 需验证 | HMAC + 密钥版本 |
| 请求是新鲜的 | timestamp | 需验证 | 与本地时钟偏差 ≤5 分钟 |
| 请求只处理一次 | nonce | 需验证 | 时间窗内去重 |
| 金额与订单匹配 | 回调 JSON | 需验证 | 与本地订单逐项比对 |
签名覆盖的规范串必须包含 provider、kid、timestamp、nonce 和请求体的 SHA-256。包含 kid 是为了防止降级:攻击者不能把新密钥签的名改成旧密钥 ID 让服务端用弱密钥验证。包含 provider 是为了多渠道路由时不能跨渠道复用签名。包含 body 哈希而不是 body 本身,是为了让规范串长度固定,也避免在日志里拼接原始请求体。时钟偏差用绝对值判断,未来时间同样拒绝。只拒绝“太旧”的请求,会给攻击者留下把时间戳改到未来的窗口;渠道时钟异常时未来时间同样应该被挡下。5 分钟是回调延迟与重试间隔的折中,不是行业标准;渠道重试周期长的场景要相应放宽,同时把 nonce 保留期一起放宽。
验签器:密钥环与有序检查
Keyring 按 kid 存密钥,每个密钥带 NotBefore 和 NotAfter。Lookup 在给定时刻只返回窗口内的密钥;窗口外一律视为未知密钥,不区分“还没生效”和“已退休”,避免向调用方泄漏密钥生命周期。Add 拒绝空 ID、过短密钥、空窗口和重复 ID。Verify 的顺序是刻意设计的:先选密钥、再验签、再看时间、再查 nonce、最后核对金额。先验签可以避免未签名的请求消耗 nonce 存储;先查时间再查 nonce,可以让过期请求不进入去重表。若顺序反过来,攻击者可以用伪造请求占用正常回调的 nonce,造成合法回调被误判为重放。
// file: internal/paytrust/callback.go
// tier: L1
// verify: go test ./internal/paytrust/
package paytrust
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
var (
ErrUnknownKey = errors.New("paytrust: unknown or inactive key")
ErrBadSignature = errors.New("paytrust: bad signature")
ErrStaleTimestamp = errors.New("paytrust: timestamp outside allowed skew")
ErrReplay = errors.New("paytrust: nonce already used")
ErrBadPayload = errors.New("paytrust: malformed payload")
ErrAmountMismatch = errors.New("paytrust: amount or currency mismatch")
)
type Key struct {
ID string
Secret []byte
NotBefore time.Time
NotAfter time.Time // 旧密钥在轮换重叠期内仍可验签
}
type Keyring struct {
keys map[string]Key
}
func NewKeyring() *Keyring {
return &Keyring{keys: make(map[string]Key)}
}
func (k *Keyring) Add(key Key) error {
if key.ID == "" || len(key.Secret) < 16 {
return errors.New("paytrust: key id and at least 16 secret bytes required")
}
if key.NotAfter.IsZero() || !key.NotAfter.After(key.NotBefore) {
return errors.New("paytrust: key validity window is empty")
}
if _, dup := k.keys[key.ID]; dup {
return fmt.Errorf("paytrust: duplicate key id %q", key.ID)
}
k.keys[key.ID] = key
return nil
}
func (k *Keyring) Lookup(id string, at time.Time) (Key, bool) {
key, ok := k.keys[id]
if !ok || at.Before(key.NotBefore) || !at.Before(key.NotAfter) {
return Key{}, false
}
return key, true
}
type Callback struct {
Provider string
KeyID string
Timestamp int64
Nonce string
Body []byte
Signature string // hex(HMAC-SHA256)
}
type Order struct {
ID string
AmountCents int64
Currency string
}
type Event struct {
EventID string `json:"event_id"`
OrderID string `json:"order_id"`
AmountCents int64 `json:"amount_cents"`
Currency string `json:"currency"`
Status string `json:"status"`
}
type Verifier struct {
ring *Keyring
now func() time.Time
maxSkew time.Duration
seen map[string]time.Time
}
func NewVerifier(ring *Keyring, maxSkew time.Duration) *Verifier {
return &Verifier{ring: ring, now: time.Now, maxSkew: maxSkew, seen: make(map[string]time.Time)}
}
// Verify 的顺序是:选密钥、验签、看时钟、查重放、核对金额。
// 先验签再消费 nonce,避免攻击者用伪造请求把正常回调的 nonce 占掉。
func (v *Verifier) Verify(cb Callback, expected Order) (Event, error) {
var zero Event
now := v.now()
key, ok := v.ring.Lookup(cb.KeyID, now)
if !ok {
return zero, ErrUnknownKey
}
if !validSignature(key.Secret, cb) {
return zero, ErrBadSignature
}
skew := now.Unix() - cb.Timestamp
if skew < 0 {
skew = -skew
}
if skew > int64(v.maxSkew/time.Second) {
return zero, ErrStaleTimestamp
}
replayKey := cb.Provider + "|" + cb.KeyID + "|" + cb.Nonce
if _, used := v.seen[replayKey]; used {
return zero, ErrReplay
}
v.prune(now)
v.seen[replayKey] = now.Add(v.maxSkew)
var ev Event
if err := json.Unmarshal(cb.Body, &ev); err != nil {
return zero, ErrBadPayload
}
if ev.EventID == "" || ev.OrderID == "" || (ev.Status != "SUCCEEDED" && ev.Status != "FAILED") {
return zero, ErrBadPayload
}
if ev.Status == "SUCCEEDED" {
if ev.OrderID != expected.ID || ev.AmountCents != expected.AmountCents || ev.Currency != expected.Currency {
return zero, ErrAmountMismatch
}
}
return ev, nil
}
func (v *Verifier) prune(now time.Time) {
if len(v.seen) < 1024 {
return
}
for k, expires := range v.seen {
if !now.Before(expires) {
delete(v.seen, k)
}
}
}
func canonical(cb Callback) string {
sum := sha256.Sum256(cb.Body)
return cb.Provider + "\n" + cb.KeyID + "\n" +
strconv.FormatInt(cb.Timestamp, 10) + "\n" + cb.Nonce + "\n" +
hex.EncodeToString(sum[:])
}
func validSignature(secret []byte, cb Callback) bool {
got, err := hex.DecodeString(cb.Signature)
if err != nil {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(canonical(cb)))
return hmac.Equal(got, mac.Sum(nil))
}
seen 是内存 map,只适合单实例演示。生产环境要换成 Redis 或数据库,键带 TTL,写入用 SET NX 保证并发下只有一个请求成功;否则同一 nonce 的两个并发请求都可能通过“查不到”这一关。prune 也只是兜底,不能替代 TTL。金额核对只对 SUCCEEDED 做。失败通知不改变订单金额,但仍要记录事件并保证幂等;如果对失败通知也做金额比对,渠道返回的“失败金额”格式差异会造成误报。状态值只接受白名单,未知状态按坏载荷拒绝,避免渠道新增状态被当成成功处理。
失败路径与轮换窗口的测试
测试覆盖:正常签名、篡改 body、未知/退休密钥、过期时间戳、金额不一致、重放,以及密钥轮换的三个时间点。签名辅助函数用与生产相同的 canonical,避免测试自己拼一套规范串——那样测的就不是同一套协议。
// file: internal/paytrust/callback_test.go
// tier: L1
// verify: go test ./internal/paytrust/
package paytrust
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"testing"
"time"
)
var fixedNow = time.Date(2026, 10, 10, 16, 0, 0, 0, time.UTC)
func testKeys() (*Keyring, Key, Key) {
ring := NewKeyring()
k1 := Key{ID: "k1", Secret: []byte("old-secret-0123456789"), NotBefore: fixedNow.Add(-24 * time.Hour), NotAfter: fixedNow.Add(time.Hour)}
k2 := Key{ID: "k2", Secret: []byte("new-secret-9876543210"), NotBefore: fixedNow, NotAfter: fixedNow.Add(24 * time.Hour)}
if err := ring.Add(k1); err != nil {
panic(err)
}
if err := ring.Add(k2); err != nil {
panic(err)
}
return ring, k1, k2
}
func body(orderID string, cents int64) []byte {
return []byte(`{"event_id":"evt_01","order_id":"` + orderID + `","amount_cents":` +
strconv.FormatInt(cents, 10) + `,"currency":"CNY","status":"SUCCEEDED"}`)
}
func signed(key Key, cb Callback) Callback {
mac := hmac.New(sha256.New, key.Secret)
mac.Write([]byte(canonical(cb)))
cb.Signature = hex.EncodeToString(mac.Sum(nil))
return cb
}
func callback(key Key, ts int64, nonce string, payload []byte) Callback {
return signed(key, Callback{Provider: "acme-pay", KeyID: key.ID, Timestamp: ts, Nonce: nonce, Body: payload})
}
func TestVerifyAcceptsSignedCallback(t *testing.T) {
ring, k1, _ := testKeys()
v := NewVerifier(ring, 5*time.Minute)
v.now = func() time.Time { return fixedNow }
expected := Order{ID: "ord_01", AmountCents: 1999, Currency: "CNY"}
ev, err := v.Verify(callback(k1, fixedNow.Unix(), "n-1", body("ord_01", 1999)), expected)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.EventID != "evt_01" || ev.AmountCents != 1999 {
t.Fatalf("event = %+v", ev)
}
}
func TestVerifyRejectsBadInputs(t *testing.T) {
ring, k1, _ := testKeys()
expected := Order{ID: "ord_01", AmountCents: 1999, Currency: "CNY"}
newVerifier := func() *Verifier {
v := NewVerifier(ring, 5*time.Minute)
v.now = func() time.Time { return fixedNow }
return v
}
tampered := callback(k1, fixedNow.Unix(), "n-2", body("ord_01", 1999))
tampered.Body = body("ord_01", 1)
if _, err := newVerifier().Verify(tampered, expected); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered err = %v", err)
}
unknownKey := callback(k1, fixedNow.Unix(), "n-3", body("ord_01", 1999))
unknownKey.KeyID = "k9"
if _, err := newVerifier().Verify(unknownKey, expected); !errors.Is(err, ErrUnknownKey) {
t.Fatalf("unknown key err = %v", err)
}
stale := callback(k1, fixedNow.Add(-6*time.Minute).Unix(), "n-4", body("ord_01", 1999))
if _, err := newVerifier().Verify(stale, expected); !errors.Is(err, ErrStaleTimestamp) {
t.Fatalf("stale err = %v", err)
}
mismatch := callback(k1, fixedNow.Unix(), "n-5", body("ord_01", 100))
if _, err := newVerifier().Verify(mismatch, expected); !errors.Is(err, ErrAmountMismatch) {
t.Fatalf("amount err = %v", err)
}
v := newVerifier()
cb := callback(k1, fixedNow.Unix(), "n-6", body("ord_01", 1999))
if _, err := v.Verify(cb, expected); err != nil {
t.Fatalf("first delivery: %v", err)
}
if _, err := v.Verify(cb, expected); !errors.Is(err, ErrReplay) {
t.Fatalf("replay err = %v", err)
}
}
func TestKeyRotationWindow(t *testing.T) {
ring, k1, k2 := testKeys()
expected := Order{ID: "ord_01", AmountCents: 1999, Currency: "CNY"}
verifyAt := func(at time.Time, key Key, nonce string) error {
v := NewVerifier(ring, 5*time.Minute)
v.now = func() time.Time { return at }
_, err := v.Verify(callback(key, at.Unix(), nonce, body("ord_01", 1999)), expected)
return err
}
if err := verifyAt(fixedNow, k1, "rot-1"); err != nil {
t.Fatalf("old key inside overlap: %v", err)
}
if err := verifyAt(fixedNow, k2, "rot-2"); err != nil {
t.Fatalf("new key at start: %v", err)
}
if err := verifyAt(fixedNow.Add(2*time.Hour), k1, "rot-3"); !errors.Is(err, ErrUnknownKey) {
t.Fatalf("retired key err = %v", err)
}
if err := verifyAt(fixedNow.Add(-time.Hour), k2, "rot-4"); !errors.Is(err, ErrUnknownKey) {
t.Fatalf("not-yet-active key err = %v", err)
}
}
实测输出
模块为 archlab(go 1.26.0),gofmt -l . 与 go vet ./... 无输出,测试结果:
=== RUN TestVerifyAcceptsSignedCallback
--- PASS: TestVerifyAcceptsSignedCallback (0.00s)
=== RUN TestVerifyRejectsBadInputs
--- PASS: TestVerifyRejectsBadInputs (0.00s)
=== RUN TestKeyRotationWindow
--- PASS: TestKeyRotationWindow (0.00s)
PASS
ok archlab/internal/paytrust 0.269s
密钥轮换的时间窗
轮换不是“换一个 secret”。正确顺序是:先让新密钥进入 Keyring,NotBefore 设在计划切换时刻;渠道侧开始用新 kid 签名;旧密钥保留到 NotAfter,覆盖最大回调延迟加重试窗口;确认旧 kid 在观察期内不再出现,再删除旧密钥。任何一步提前,都会让在途回调验签失败。
| 阶段 | 新密钥 | 旧密钥 | 渠道行为 |
|---|---|---|---|
| 准备 | 已加入,未生效 | 唯一可用 | 仍旧签名 |
| 重叠 | 生效 | 仍可用 | 新签新 kid,重试可能旧 kid |
| 退旧 | 唯一可用 | 窗口结束 | 全部新 kid |
| 清理 | 唯一可用 | 删除 | 不再接受旧 kid |
重叠窗至少等于“回调最大延迟 + 重试总时长 + 时钟偏差”。arch-lab 用 1 小时重叠、5 分钟偏差,是为了演示;真实渠道可能重试 24 小时,窗口就要覆盖 24 小时。窗口太短,故障表现为“支付成功但订单一直待支付”,而且只在轮换当天出现,很难复现。密钥版本选择必须由请求里的 kid 决定,不能“用当前密钥验,失败再试旧的”:后者会让验证成本随密钥数量增长,也会在旧密钥泄漏后继续给它验证机会。
ADR:本地核对还是主动查单
| 方案 | 做法 | 后果 |
|---|---|---|
| A 只验签 | 签名通过就置成功 | 金额被篡改、渠道 bug 都会直接进账 |
| B 验签 + 本地核对 | 比对订单号、金额、币种 | 依赖本地订单准确;渠道异步场景可能延迟 |
| C 验签 + 主动查单 | 再调渠道查询接口确认 | 多一次外部依赖,回调处理变慢,仍需幂等 |
arch-lab 选 B。理由:回调已经带签名,金额与本地订单比对能挡住绝大多数篡改;主动查单把回调路径变成同步外部调用,渠道查询接口抖动会拖垮支付回调,而且查单结果仍要与本地订单核对,收益有限。代价是渠道侧“扣款成功但回调金额字段异常”时,订单会停在待支付,需要每日对账兜底。这个后果必须写进 ADR,而不是假设回调永远正确。
不选 A 是因为它把“签名有效”等同于“内容正确”:渠道 bug、测试环境串号、商户配置错误都可能产生签名正确但金额错误的回调。不选 C 不是因为它错,而是因为它把可用性风险从“渠道回调延迟”扩大到“渠道查询接口延迟”,在当前渠道 SLA 下不划算。
常见错误与边界
第一,先消费 nonce 再验签。伪造请求可以占掉正常回调的 nonce。第二,只检查时间戳“不太旧”。未来时间同样要拒绝。第三,金额只比总额不比币种,或者干脆信任回调金额。第四,重放表没有 TTL 或没有并发原子性,单机测试通过、扩容后失效。第五,轮换时立刻删除旧密钥。在途回调和重试会全部失败。第六,用“当前密钥失败再试旧密钥”代替 kid 选择,既慢又扩大泄漏面。第七,把 secret 写进配置仓库或日志。HMAC 密钥、渠道私钥、完整的 Authorization 头都属于上一节的 secret 级,不能落日志。
边界也要说清楚:HMAC 共享密钥适用于单一渠道的双向信任,跨组织场景更适合非对称签名;本节的内存 nonce 表不是生产方案,生产要用带 TTL 和 SET NX 的共享存储;时钟同步依赖 NTP,偏差监控要独立告警;退款、部分支付、多币种结算需要各自的金额模型,不能直接复用这里的“全额成功”判断。零信任的整体原则可以回看 零信任架构
,支付渠道只是其中一个需要单独建模的信任边界。
小结
支付回调是外部输入:签名证明来源,时间窗证明新鲜,nonce 证明只处理一次,金额核对证明内容与本地订单一致。arch-lab 用 Keyring 按 kid 和有效期选密钥,Verify 按“选密钥、验签、看时钟、查重放、核对金额”的顺序执行;三组测试覆盖正常回调、篡改、未知密钥、过期、金额不一致、重放和轮换三个时间点。密钥轮换的关键不是换 secret,而是重叠窗和 kid 选择;本地核对加每日对账是当前渠道下可接受的折中。
阅读导航:上一节:10.2 输入边界与数据分级 · 下一节:11.1 部署单元与故障域:订单系统怎么切 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。