1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
2import * as NodeCrypto from "node:crypto";
3
4import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts";
5
6/** Constant-time string comparison that does not leak length through timing. */
7export function constantTimeEquals(a: string, b: string): boolean {
8 const digestA = NodeCrypto.createHash("sha256").update(a).digest();
9 const digestB = NodeCrypto.createHash("sha256").update(b).digest();
10 return NodeCrypto.timingSafeEqual(digestA, digestB);
11}
12
13/**
14 * Checks an HMAC-SHA256 signature over the raw body bytes, as GitHub, Linear,
15 * Shopify and most other signing senders do. The header value is
16 * `<prefix><digest>`, with the digest hex- or base64-encoded.
17 */
18export function verifyWebhookSignature(input: {
19 readonly signature: ScheduledTaskWebhookSignature;
20 readonly secret: string;
21 readonly headers: Readonly<Record<string, string>>;
22 readonly body: Uint8Array;
23}): boolean {
24 const received = input.headers[input.signature.header.toLowerCase()];
25 if (received === undefined) return false;
26 const value = received.trim();
27 const prefix = input.signature.prefix;
28 if (prefix !== "" && !value.toLowerCase().startsWith(prefix.toLowerCase())) return false;
29 const digest = NodeCrypto.createHmac("sha256", input.secret).update(input.body).digest();
30 const expected =
31 input.signature.encoding === "hex" ? digest.toString("hex") : digest.toString("base64");
32 const candidate = value.slice(prefix.length);
33 return constantTimeEquals(
34 input.signature.encoding === "hex" ? candidate.toLowerCase() : candidate,
35 expected,
36 );
37}