/** * Verify an inbound webhook signature. * * Customer use: * * import { verifyWebhookSignature } from "@a4anthony/proctorkit-sdk"; * * app.post("/webhooks/proctor", express.raw(...), async (req, res) => { * const ok = await verifyWebhookSignature({ * body: req.body.toString("utf8"), * header: req.header("X-Proctoring-Signature") ?? "", * secret: process.env.PROCTOR_WEBHOOK_SECRET, * }); * if (!ok) return res.status(401).end(); * // ...handle the event * }); * * The header format is `t=,v1=`. The signed * string is `${t}.${body}` — the same scheme the server uses, the * same Stripe uses. The timestamp protects against replay attacks * (default tolerance 300s). * * Uses Web Crypto so it runs in Node (>=15), browsers, edge runtimes * (Cloudflare Workers, Vercel Edge, Deno), and any V8 isolate that * exposes `crypto.subtle`. No `node:crypto` import — the SDK ships * to browsers and we want one helper for both server and edge. * * Constant-time-equivalent comparison via the byte-by-byte XOR * accumulator below. Web Crypto exposes no `timingSafeEqual` so we * inline the equivalent. */ export interface VerifyWebhookOptions { /** Raw request body as a string (do NOT JSON.parse first). */ body: string; /** The `X-Proctoring-Signature` header value. */ header: string; /** The signing secret you copied from the dashboard. */ secret: string; /** * Reject the signature if the timestamp is older than this many * seconds. Defaults to 300 (5 minutes) — matches Stripe. */ toleranceSeconds?: number; } export async function verifyWebhookSignature( opts: VerifyWebhookOptions, ): Promise { const { body, header, secret, toleranceSeconds = 300 } = opts; if (typeof header !== "string" || header.length === 0) return false; if (typeof secret !== "string" || secret.length === 0) return false; if (typeof body !== "string") return false; const parts = parseHeader(header); if (!parts) return false; const { timestamp, providedSig } = parts; // Replay window check. We do this BEFORE the HMAC compute so an // attacker who knows the secret still can't replay an old payload // they captured. const nowSec = Math.floor(Date.now() / 1000); if (Math.abs(nowSec - timestamp) > toleranceSeconds) return false; // Compute the expected HMAC. const expected = await hmacSha256Hex(secret, `${timestamp}.${body}`); return constantTimeEqual(providedSig, expected); } function parseHeader( header: string, ): { timestamp: number; providedSig: string } | null { // Format: "t=,v1=" — order-insensitive, ignore other // segments (forward-compat for future v2 schemes). let timestamp: number | null = null; let providedSig: string | null = null; for (const segment of header.split(",")) { const eq = segment.indexOf("="); if (eq <= 0) continue; const key = segment.slice(0, eq).trim(); const value = segment.slice(eq + 1).trim(); if (key === "t") { const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) timestamp = parsed; } else if (key === "v1") { if (/^[a-f0-9]+$/i.test(value)) providedSig = value.toLowerCase(); } } if (timestamp === null || providedSig === null) return null; return { timestamp, providedSig }; } async function hmacSha256Hex(secret: string, message: string): Promise { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const key = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); const bytes = new Uint8Array(sig); let out = ""; for (let i = 0; i < bytes.length; i++) { out += bytes[i]!.toString(16).padStart(2, "0"); } return out; } /** * Byte-by-byte equality with an XOR accumulator — runtime * independent of position of first differing byte. Equivalent to * Node's `crypto.timingSafeEqual` semantics, hand-rolled because * Web Crypto has no such primitive. */ function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; }