订单状态有时会倒退 — 把接收的那一侧做出来
目标
亲手制作接收对方系统推送的 Webhook 的一方。包括 HMAC 签名校验和常数时间比较、防止重放的时间窗口、用投递编号和事件编号两个键去重、用版本比较吸收顺序颠倒,以及快速返回 200 再稍后处理的结构,并把一天的投递重新放一遍,统计每个分支。
为什么重要
Webhook 不是我们去调用,而是我们去接收,所以控制权在对方手里。接收端点必须开放,因此任何人都可以 POST;如果我们的响应晚了,对方就会重发;顺序也得不到保证。 所以接收的一方必须自己判断四件事。是谁发的(签名),是什么时候发的(时间窗口),是不是已经见过(重复),是不是比当前的更新(版本)。其中只要缺了一项,伪造的事件就会进入账本,或者订单状态会倒退。 去重的键有两个,这一点尤其是陷阱。投递编号指一次传输,事件编号指发生的一个事件。需要阻止的是事件被重复应用,所以只用投递编号过滤,只能挡住一半。 评分器不会相信你写的句子。评分器会把你制作的发送器和接收器启动在评分器所选的端口上,再用评分器生成的密钥和投递重新运行校验器和账本,核对答案。
步骤
- 创建 /root/wh/sender.py 并在端口 8012 上启动,将
/deliveries保存到 /root/wh/deliveries.json,将共享密钥保存到 /root/wh/secret.txt。 - 创建 /root/wh/verify.py,使它以常数时间比较签名,并返回
{"ok": ..., "reason": ...}。 - 给 verify.py 加上时间窗口,使它把窗口之外的投递以
stale拒绝。 - 创建 /root/wh/ledger.py,使它用投递编号和事件编号两个键过滤重复。
- 让 ledger.py 只在比当前保存的版本更新时才应用,对后到的旧版本记为
stale_version。 - 创建 /root/wh/receiver.py,使它只做校验并放入队列,然后立刻返回 200,并通过
--drain把队列排入账本。 - 用 /root/wh/replay_day.py 把一天的投递全部重新放一遍,生成 /root/wh/day.db 和 /root/wh/result.json。
- 在 /root/wh/wh_report.md 中分四节进行汇报。
参考
- 发送器的运行契约:
python3 /root/wh/sender.py --port <포트> [--secret <비밀>](占位符依次为端口、密钥)。/health返回{"ok": true, "events": 30, "deliveries": 41},/deliveries返回{"now": <기준 시각>, "tolerance": 300, "deliveries": [...]}(占位符为基准时刻)。每条投递是{"delivery_id": ..., "signature": ..., "body": <원본 문자열>}(占位符为原始字符串)。 - 签名格式:
t=<epoch>,v1=<hex>。签名原料是"<t>.<body>",算法为 HMAC-SHA256。body 按收到的字符串原样使用。如果重新解析再序列化,签名就会对不上。 - 这一天的数据是 41 条:30 个事件(10 笔订单 × 3 个版本),再加上 4 条重发、3 条同一事件的新投递、2 条陈旧的重放、2 条伪造签名。版本到达的顺序因订单而异。
- 校验器的运行契约:
python3 verify.py --secret <파일> --delivery <파일> [--now <epoch>] [--tolerance <초>](占位符依次为密钥文件、投递文件、秒数)会返回{"ok": true|false, "reason": "ok"|"bad_signature"|"stale"|"malformed"}。不指定--now时使用当前时刻。在第 2 步中也请接收这四个参数(窗口在第 3 步加上)。不是签名的形态就是 malformed,签名错误就是 bad_signature,签名正确但时刻在窗口之外就是 stale。 - 账本的运行契约:
python3 ledger.py --db <sqlite> --delivery <파일>(占位符为文件名)会返回{"stored": ..., "applied": ..., "reason": ...}。reason 有 new · duplicate_delivery · duplicate_event · stale_version。表中必须包含order_state(order_id, version, status)。 - 接收端点的运行契约:
python3 receiver.py --port <포트> --db <sqlite> --secret <파일> [--tolerance <초>](占位符依次为端口、密钥文件、秒数)提供GET /health和POST /webhook。投递编号通过X-Delivery-Id、签名通过X-Signature请求头传来。通过时返回 200{"queued": true},在签名或时间窗口处被拦下时返回 400。指定--drain时不启动服务器,而是把队列排入账本,然后输出统计。队列表的名称是inbox。 - 重放器的运行契约:
python3 replay_day.py --deliveries <파일> --db <sqlite> --out <파일>(占位符为文件名)。统计栏共 10 个:deliveries · accepted · rejected_signature · rejected_stale · duplicate_delivery · duplicate_event · stored · applied · stale_version · orders。accepted是通过了签名和时间窗口的投递,stored是因不重复而进入账本的事件数。 - 常见错误:解析正文后再序列化来计算签名;用
==比较签名;只用投递编号过滤重复;在接收的位置把账本也处理完,导致 200 返回得晚。 - 服务器要放在后台启动,等到
/health返回 200 之后再继续。评分器不会查看你启动的进程,而是直接重新启动脚本。
拿到一天的投递
创建 /root/wh/sender.py 并在端口 8012 上启动,将 /deliveries 的响应保存到 /root/wh/deliveries.json,将共享密钥保存到 /root/wh/secret.txt。投递有 41 条,事件有 30 个。
用 flask 创建 /health 和 /deliveries 两条路径。投递列表是在 30 个事件的基础上,加上重发、同一事件的新投递、陈旧的重放和伪造签名构成的。如果在响应中一并带上基准时刻,之后测试就不会受时钟影响。
用签名分辨是谁发的
创建 /root/wh/verify.py,使它校验一条投递的签名,并返回 {"ok": ..., "reason": ...}。不是签名的形态就是 malformed,签名错误就是 bad_signature。比较必须用常数时间。
拆开签名字符串 t=...,v1=... 得到 t 和 v1,以 "<t>.<body>" 为原料计算 HMAC-SHA256。body 必须按收到的字符串原样使用。Python 的 hmac 模块中有以与长度成正比的时间进行比较的函数——== 会在第一个不同的字节处结束,时间就会泄露密钥。
重新塞入旧请求的那只手
给 verify.py 加上时间窗口。即使签名正确,只要 --now 与投递中 t 的差值超过 --tolerance(默认 300 秒),就必须以 stale 拒绝。前后两个方向都以窗口为界。
仅凭签名,无法阻止把以前传过的有效请求原样再塞进来。所以签名原料中包含时间戳,接收的一方要查看它与当前时刻之差。未来一侧也必须拦住——对方时钟快,与有人把 t 写成未来,这两者无法区分。顺序上,签名在先。
有两个键
创建 /root/wh/ledger.py,使它把通过校验的投递放入账本,但如果同一个投递编号再次到来,就以 duplicate_delivery 过滤;如果投递编号是新的,而事件编号已经存在,就以 duplicate_event 过滤。表必须保存在 sqlite 文件中。
先查询、不存在再插入的方式,会让同一时刻进来的两条都通过。请直接对以两个键分别作为主键的表执行 INSERT,并把违反约束的异常当作“已经存在”的信号。投递编号的检查在先——如果顺序反过来,重发就会被记为 duplicate_event。
不让订单状态倒退
让 ledger.py 设置 order_state(order_id, version, status),并且只有比当前保存的版本更新时才应用。对于后到的旧版本,stored 为 true,但 applied 为 false,reason 为 stale_version。
不能相信到达顺序。请把事件自带的 version 与当前保存的值做比较。如果是第一次见到的订单就直接放入,已经存在则只有在更大时才更新。同一个版本再次到来,也不属于更新对象。
尽快返回 200,稍后再处理
创建 /root/wh/receiver.py,使 POST /webhook 只检查签名和时间窗口,放入 inbox 队列后立刻返回 200 {"queued": true}。不能在接收的位置去动账本。--drain 不启动服务器,而是把队列排入账本。
投递编号通过 X-Delivery-Id、签名通过 X-Signature 请求头传来。正文不要解析,要把原始字符串原样交给校验。校验时被拦下就是 400。把放入队列和应用到账本分开,处理时间就不会触碰对方的超时。
把一天的数据毫无遗漏地重新放一遍
用 /root/wh/replay_day.py 把 /root/wh/deliveries.json 中的投递全部重新放一遍,生成 /root/wh/day.db 和 /root/wh/result.json。统计栏共 10 个:deliveries·accepted·rejected_signature·rejected_stale·duplicate_delivery·duplicate_event·stored·applied·stale_version·orders。
请把投递列表文件中的 now 和 tolerance 原样用作基准时刻。这样测试才不会受时钟影响。在校验中被淘汰的不会进入账本,因重复被拦下的不计入 stored。请一条不漏地全部放一遍。
接收方检查报告
在 /root/wh/wh_report.md 中分为 ## 무엇이 들어왔나 ## 중복을 어떻게 걸렀나 ## 순서를 어떻게 다뤘나 ## 남은 위험과 운영 규칙 四节来写(韩文,依次意为“进来了什么”“如何过滤重复”“如何处理顺序”“剩余风险与运维规则”)。result.json 中的数字必须写进正文。
读者既可能是我们的团队负责人,也可能是合作方的负责人。请用数字写出每个分支各有多少条,如果只用投递编号过滤,还要写下会漏掉什么。剩余风险中必须包含密钥轮换和时间窗口的宽度。