功能说明
该示例实现 Webhook 的签名与验签。签名内容由时间戳与原始请求体拼接,使用共享密钥做 HMAC-SHA256,输出形如 t=时间戳,v1=十六进制签名的头部。验签时同时检查时间戳是否在容差内、签名长度是否一致,并用常量时间比较避免通过响应时间猜测签名。
关键代码
signWebhook 要求密钥至少十六个字符,把时间戳、点号与载荷交给 createHmac 计算十六进制摘要。verifyWebhook 把头部按逗号拆成键值对,取 t 与 v1,先用 now 除以一千与时间戳比较,超出 toleranceSeconds 直接返回 false。随后重新计算期望签名,通过 fromHex 转成字节数组,长度不同立即失败,长度相同时调用 timingSafeEqual。fromHex 拒绝奇数长度与非十六进制字符。
使用注意
必须使用原始请求体验签,框架若先解析 JSON 再序列化会改变空白与字段顺序,导致签名不匹配。时间戳容差只能缩小重放窗口,不能完全阻止重放,关键操作还应记录事件 ID 做幂等。密钥应来自环境变量或密钥管理服务,不能写进代码或日志。比较必须使用常量时间函数,普通字符串相等比较会泄露前缀信息。
验证方式
对固定载荷与时间戳签名,断言在容差内验签通过。修改载荷、追加空格或使用过期时间戳都应返回 false。测试缺失 v1、非十六进制签名、奇数长度签名与过短密钥,分别返回 false 或抛 RangeError。再验证相同输入生成的签名完全一致。
代码示例
import { createHmac, timingSafeEqual } from "node:crypto";
export interface SignatureOptions {
readonly secret: string;
readonly toleranceSeconds?: number;
readonly now?: number;
}
export function signWebhook(payload: string, timestamp: number, secret: string): string {
if (typeof secret !== "string" || secret.length < 16) {
throw new RangeError("secret must contain at least 16 characters");
}
if (!Number.isInteger(timestamp)) throw new TypeError("timestamp must be an integer");
const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
return `t=${timestamp},v1=${signature}`;
}
export function verifyWebhook(payload: string, header: string, options: SignatureOptions): boolean {
if (typeof header !== "string") throw new TypeError("header must be a string");
const { secret, toleranceSeconds = 300, now = Date.now() } = options;
if (typeof secret !== "string" || secret.length < 16) {
throw new RangeError("secret must contain at least 16 characters");
}
const parts = new Map<string, string>();
for (const part of header.split(",")) {
const equals = part.indexOf("=");
if (equals === -1) continue;
parts.set(part.slice(0, equals).trim(), part.slice(equals + 1).trim());
}
const timestampText = parts.get("t");
const provided = parts.get("v1");
if (timestampText === undefined || provided === undefined) return false;
const timestamp = Number(timestampText);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
const left = fromHex(expected);
const right = fromHex(provided);
if (left.length === 0 || left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
function fromHex(value: string): Uint8Array {
if (value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) return new Uint8Array(0);
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
const secret = "0123456789abcdef0123456789abcdef";
const payload = JSON.stringify({ event: "invoice.paid", id: "evt_42" });
const header = signWebhook(payload, 1760000000, secret);
console.log(header);
console.log(verifyWebhook(payload, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(`${payload} `, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(payload, header, { secret, toleranceSeconds: 10, now: 1760000100 * 1000 }));