openlang

仅有一个 webhook 的 HMAC 不匹配(名字里带重音符号的那个)

我写了个小的 Node 检查器(纯 JS,不需要构建步骤),用来重放保存下来的 webhook 投递,并在下游信任它们之前校验 HMAC-SHA256。三个里有两个能过。来自 billing-partner 的 evt_2044 失败了,但同一个合作方的 evt_2051 用同一个密钥却能通过。

$ node main.js
staging         evt_1001  ok
billing-partner evt_2044  FAIL (digest)
billing-partner evt_2051  ok

两个密钥上周都轮换过,所以我一开始怀疑是密钥过期。但那样的话 evt_2051 也过不了才对。我能看到的唯一区别是 2044 里的客户名带重音符号,叫 Zoë Ramírez。

所以我怀疑 payload 传进 createHmac 的过程中某处存在 utf8 和 latin1 的问题。我试过改 update() 的编码参数,完全没区别。

lib/verify.js 的核心部分如下。

const body = JSON.parse(delivery.raw);

// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
  .createHmac("sha256", secret)
  .update(payload, "utf8")
  .digest("hex");

合作方文档说签名是对“JSON payload”做的(我挺确定的),所以我不知道还该对什么做哈希。我漏了什么?为什么只有那一个事件有问题?

(toleranceSeconds 的重放检查还是 TODO,别管它。)

从英语翻译查看原文

后续讨论 7

顺着彼此的依据读下去
  1. 固定链接

    我敢打赌问题出在 JSON.parse 再 JSON.stringify 这一来一回上,而不是传给 update() 的编码。所以你改了编码才没有任何效果。

    HMAC 是对合作方签名时的那段确切字节算的。你一旦解析再重新序列化,签的就是你自己的序列化结果,而不是他们的。对于紧凑格式的纯 ASCII payload,两者往往字节完全一致,所以 evt_2051 和 evt_1001 是碰巧通过的。evt_2044 含有非 ASCII 字符,序列化器就是在这里出现分歧的。如果合作方输出的是 Zo\u00eb Ram\u00edrez(转义形式,有些 Java/PHP/Python 编码器默认就这样),JSON.stringify 给你的却是字面的 Zoë Ramírez,摘要就变了。反过来也可能发生。键顺序、空白和数字格式也可能不同,但重音字符最符合你的现象。

    解法是直接对 delivery.raw 做哈希,不要动它。

    const expected = crypto
      .createHmac("sha256", secret)
      .update(delivery.raw)   // string or Buffer, exactly as received
      .digest("hex");

    签名校验通过之后再调用 JSON.parse。如果你把投递记录存到了磁盘,也要确认写入的那一步没有重新编码或规范化文本。直接存原始 Buffer 最保险。

    顺手再提一句,比较摘要请用 crypto.timingSafeEqual,不要用 ===。文档在 https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b

    希望有帮助,如果换成原始版本后 2044 修好了,也告诉大家一声。

    从英语翻译查看原文
  2. 固定链接

    const payload = JSON.stringify(body);

    就是这一行有问题。合作方签的是他们自己的字节,不是你的 JSON.stringify 生成的东西。对我来说,这类文档里的“the JSON payload”一直都指原始 body。直接对 delivery.raw 原样做哈希,校验完再解析。

    https://nodejs.org/api/crypto.html

    从英语翻译查看原文
  3. 固定链接

    同意对原始 body 做哈希,不过“the JSON payload 总是指原始数据”这个说法我想稍微反驳一下。文档里经常这样含糊地写,这大概就是 tess.p 一开始踩坑的原因,对吧?

    重放校验脚本还有一点要提醒。如果保存下来的投递记录是按 utf8 字符串读出来再写回去的,BOM 或 CRLF 的变化会以同样的方式破坏摘要。我会让 raw 从头到尾都保持 Buffer。另外,如果以后放在 Express 后面,你拿到 body 的时候 express.json() 已经把它解析过了。有两种绕开的办法。

    // option A: keep the bytes next to the parsed body
    app.use(express.json({
      verify: (req, _res, buf) => { req.rawBody = buf; },
    }));
    
    // option B: raw only on the webhook route, parse after verifying
    app.post("/hooks/billing", express.raw({ type: "application/json" }), (req, res) => {
      // req.body is a Buffer here
    });

    我倾向 B,因为不容易不小心哈希错对象,但 A 对现有应用的改动更小。不确定评审时我会为哪个辩护。

    从英语翻译查看原文

同一个问题的其他角度

  1. -race 报了 DATA RACE,可 ack 明明是无缓冲接收,是 time.After 的锅吗?

    会不会是竞态检测器在这里判断错了,还是我对 ack 提供的保证理解有误?这是 pushgate,一个小服务,给我的移动应用校验推送通知用的设备令牌上的 HMAC 签名。调用方在无缓冲的 channel 上发送一个 Request,worker 填好…

    回复 4最近活动 go
  2. memset 在 free 之前调用,默认编译选项下通过,OPT=-O2 时失败

    我在写一个叫 vault 的小型 C 凭据存储库,在 WSL 里构建。vault_release() 的作用是先擦除密钥再释放,代码如下 测试文件 tests/wipe_check.c 会先保存密钥指针,调用 vault_release(),然后从第…

    回复 6最近活动 c
  3. 同样的溢出检查,-O0 和 -O2 下走了不同的错误分支

    恶意构造的 pack,count=65536,rec_size=32768。-O0 下我得到 "pack too large",-O2 下却变成了 "out of memory"。源码完全一样,我真的想不通。是 -O2 把它编译错了,还是 mallo…

    回复 6最近活动 c