← 中文资源

简体中文版本

安全校验 Webhook HMAC 签名

用 HMAC-SHA256 签名并校验 Webhook 载荷,结合时间戳容差与常量时间比较抵御重放和时序攻击。

阅读英文原文

功能说明

该示例实现 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 }));