症状
失败模式指向基础设施而非密码学:代码未改,一次框架升级或新增的 body 解析中间件让所有 webhook 全挂。机制是——provider 对精确字节 {“a”:1,“b”:2} 签名,中间件解析后重新序列化得到键序不同的 {“b”:2,“a”:1},校验者对后者做 HMAC,必然失配。编码孪生同样机械:拿 hex 摘要对比 base64 头,每个事件都失败。事件与载荷大小或内容(unicode、换行)相关、小事件通过大事件失败,指向解析器限制在校验前截断了流。部分事件通过部分失败、随内容相关,都是字节差异的指纹。
诊断思路
先对 provider 文档核对方案:哪个头、哪种编码(hex/base64)、签名字符串含哪些字段、时间戳格式——严格照做;再确认路由上 raw body 捕获先于解析器,原始字节没了任何算法都救不了;最后用已知载荷本地计算摘要对比头值,分离编码、顺序、密钥三类问题。
根因
签名是对 provider 发出的精确原始字节计算的。中间件先解析再序列化后,校验者哈希的是不同字节,失败必然。同类 bug 还表现为编码不匹配(hex 对 base64)或时间窗拒绝合法时钟偏移内的重放。
修复与验证
webhook 路由先于一切解析器捕获 raw body(express.raw 限定该路由),按文档方案精确校验:hex 编码、对原始 Buffer 计算;带时间戳的方案(Stripe 的 t=…,v1=…)解析各部分、在几分钟窗口内吸收时钟偏移、按文档把时间戳与体一并签名验证;摘要比较用 timingSafeEqual 配长度预检——=== 会泄漏长度与时序。验证时:重放的已签名样本通过、篡改一字节失败、过期时间戳失败、中间件顺序有回归测试保护。
代码示例
provider signs: {"a":1,"b":2} (exact bytes)
middleware does: req.body -> JSON.stringify -> {'b':2,'a':1}
verifier hashes: {"b":2,"a":1} (different bytes)
HMAC mismatch — always
const crypto = require('node:crypto');
// 1. raw bytes BEFORE any JSON parser, on this route only:
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
// 2. exact scheme: hex, over the raw body
const expected = crypto
.createHmac('sha256', process.env.STRIPE_SECRET)
.update(req.body) // Buffer: the exact bytes
.digest('hex');
const provided = req.headers['stripe-signature'];
// 3. constant-time compare, with a length pre-check
const a = Buffer.from(expected);
const b = Buffer.from(String(provided));
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!ok) return res.status(400).end();
handle(JSON.parse(req.body.toString()));
});