TT Lab
开始
学习 学习路径 课程

集成与部署

订单状态有时会倒退 — 把接收的那一侧做出来

在 TT Lab 中继续学习

目标

亲手制作接收对方系统推送的 Webhook 的一方。包括 HMAC 签名校验和常数时间比较、防止重放的时间窗口、用投递编号和事件编号两个键去重、用版本比较吸收顺序颠倒,以及快速返回 200 再稍后处理的结构,并把一天的投递重新放一遍,统计每个分支。

为什么重要

Webhook 不是我们去调用,而是我们去接收,所以控制权在对方手里。接收端点必须开放,因此任何人都可以 POST;如果我们的响应晚了,对方就会重发;顺序也得不到保证。 所以接收的一方必须自己判断四件事。是谁发的(签名),是什么时候发的(时间窗口),是不是已经见过(重复),是不是比当前的更新(版本)。其中只要缺了一项,伪造的事件就会进入账本,或者订单状态会倒退。 去重的键有两个,这一点尤其是陷阱。投递编号指一次传输,事件编号指发生的一个事件。需要阻止的是事件被重复应用,所以只用投递编号过滤,只能挡住一半。 评分器不会相信你写的句子。评分器会把你制作的发送器和接收器启动在评分器所选的端口上,再用评分器生成的密钥和投递重新运行校验器和账本,核对答案。

步骤

  1. 创建 /root/wh/sender.py 并在端口 8012 上启动,将 /deliveries 保存到 /root/wh/deliveries.json,将共享密钥保存到 /root/wh/secret.txt。
  2. 创建 /root/wh/verify.py,使它以常数时间比较签名,并返回 {"ok": ..., "reason": ...}。
  3. 给 verify.py 加上时间窗口,使它把窗口之外的投递以 stale 拒绝。
  4. 创建 /root/wh/ledger.py,使它用投递编号和事件编号两个键过滤重复。
  5. 让 ledger.py 只在比当前保存的版本更新时才应用,对后到的旧版本记为 stale_version。
  6. 创建 /root/wh/receiver.py,使它只做校验并放入队列,然后立刻返回 200,并通过 --drain 把队列排入账本。
  7. 用 /root/wh/replay_day.py 把一天的投递全部重新放一遍,生成 /root/wh/day.db 和 /root/wh/result.json。
  8. 在 /root/wh/wh_report.md 中分四节进行汇报。

参考

拿到一天的投递

创建 /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 中的数字必须写进正文。

读者既可能是我们的团队负责人,也可能是合作方的负责人。请用数字写出每个分支各有多少条,如果只用投递编号过滤,还要写下会漏掉什么。剩余风险中必须包含密钥轮换和时间窗口的宽度。