功能说明
该示例实现 webhook 接收方的签名校验:用共享密钥、时间戳与正文计算 HMAC-SHA256,并与请求携带的签名比对。时间戳参与签名可防止重放,超出容许窗口即拒绝。比较使用恒定时间函数,避免通过响应时间逐字节推断正确签名。
关键代码
签名输入采用“时间戳 + 句点 + 正文”的拼接顺序,接收方必须与发送方完全一致,这里把这一点显式写出以免歧义。先比较两个缓冲长度,长度不同直接失败,避免 timingSafeEqual 在长度不一致时抛异常。失败返回带 reason 的对象而非抛错,便于调用方区分超时与不匹配并记录原因,同时不把密钥或期望签名泄漏进日志。
使用注意
验签必须在读取正文之后、任何解析或副作用之前进行,且所用正文须与签名计算时的字节完全一致——框架若重排 JSON 会破坏验签。容差窗口应在重放风险与时钟偏差之间权衡,通常为数分钟。密钥来自密钥管理而非硬编码,轮换时支持同时接受新旧两把密钥。本示例只做验签,不包含幂等去重,重复投递仍需按事件 id 去重。
代码示例
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook({ body, signature, timestamp, secret, toleranceMs = 300000, now = Date.now() }) {
if (typeof body !== 'string' || typeof signature !== 'string' || typeof secret !== 'string') {
throw new TypeError('body, signature and secret must be strings');
}
const ts = Number(timestamp);
if (!Number.isFinite(ts)) throw new TypeError('timestamp must be numeric');
if (Math.abs(now - ts) > toleranceMs) return { ok: false, reason: 'timestamp out of tolerance' };
const expected = createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return { ok: false, reason: 'signature length mismatch' };
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}