/** * One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=`. * The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex). * Verification is constant-time; links older than 3 days are rejected. */ async function hmacKey(secret: string): Promise { return crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ); } export async function signLink( key: string, p: number, r: string, d: string, ): Promise { const mac = await crypto.subtle.sign( "HMAC", await hmacKey(key), new TextEncoder().encode(`${p}|${r}|${d}`), ); return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join(""); } export async function verifyLink( key: string, p: number, r: string, d: string, sig: string, ): Promise { if (!/^[0-9a-f]{64}$/.test(sig)) return false; const bytes = new Uint8Array(32); for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16); // crypto.subtle.verify is constant-time; never compare hex strings directly. return crypto.subtle.verify( "HMAC", await hmacKey(key), bytes, new TextEncoder().encode(`${p}|${r}|${d}`), ); } /** Constant-time equality for webhook signatures and admin bearer keys. */ export async function timingSafeEqual(a: string, b: string): Promise { // HMAC both sides with a random key: unequal-length inputs and content // differences are equally invisible to timing. const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [ "sign", ]); const enc = new TextEncoder(); const [ma, mb] = await Promise.all([ crypto.subtle.sign("HMAC", key, enc.encode(a)), crypto.subtle.sign("HMAC", key, enc.encode(b)), ]); const va = new Uint8Array(ma); const vb = new Uint8Array(mb); let diff = 0; for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!; return diff === 0; } /** GitHub webhook `X-Hub-Signature-256: sha256=` verification. */ export async function verifyWebhook( secret: string, body: ArrayBuffer, header: string | null, ): Promise { if (!header?.startsWith("sha256=")) return false; const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body); const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join(""); return timingSafeEqual(header.slice(7), expected); }