| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | |
| 4 | import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts"; |
| 5 | |
| 6 | /** Constant-time string comparison that does not leak length through timing. */ |
| 7 | export 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 | */ |
| 18 | export 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 | } |