const toHex = (buffer: ArrayBuffer): string => Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); export const timingSafeEqualHex = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let mismatch = 0; for (let i = 0; i < a.length; i += 1) { mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); } return mismatch === 0; }; export const verifyGithubSignature = async ( secret: string, rawBody: string, signatureHeader: string | null, ): Promise => { if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false; // Web Crypto's importKey throws on a zero-length HMAC key; guard so the // verifier never throws on attacker-controlled input (returns false instead). if (!secret) return false; const provided = signatureHeader.slice('sha256='.length); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign( 'HMAC', key, new TextEncoder().encode(rawBody), ); return timingSafeEqualHex(toHex(mac), provided); };