1import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts";
2import * as Digest from "effect/crypto/Digest";
3import * as Mac from "effect/crypto/Mac";
4import * as Effect from "effect/Effect";
5import * as Base64 from "effect/encoding/Base64";
6import * as Hex from "effect/encoding/Hex";
7import * as Result from "effect/Result";
8
9const utf8 = (value: string) => new TextEncoder().encode(value);
10
11/** Compares hashes so the comparison does not leak either string's length. */
12export const constantTimeEquals = Effect.fn("webhookVerification.constantTimeEquals")(function* (
13 a: string,
14 b: string,
15) {
16 const digest = yield* Digest.Digest;
17 const [left, right] = yield* Effect.all([
18 digest.digest(Digest.sha256, utf8(a)),
19 digest.digest(Digest.sha256, utf8(b)),
20 ]);
21 return Mac.timingSafeEqual(left, right);
22});
23
24export const verifyWebhookSignature = Effect.fn("webhookVerification.verifyWebhookSignature")(
25 function* (input: {
26 readonly signature: ScheduledTaskWebhookSignature;
27 readonly secret: string;
28 readonly headers: Readonly<Record<string, string>>;
29 readonly body: Uint8Array;
30 }) {
31 const received = input.headers[input.signature.header.toLowerCase()];
32 if (received === undefined) return false;
33 const value = received.trim();
34 const prefix = input.signature.prefix;
35 if (prefix !== "" && !value.toLowerCase().startsWith(prefix.toLowerCase())) return false;
36 const candidate = value.slice(prefix.length);
37 const tag =
38 input.signature.encoding === "hex"
39 ? Hex.decode(candidate.toLowerCase())
40 : Base64.decode(candidate);
41 if (Result.isFailure(tag)) return false;
42 const mac = yield* Mac.Mac;
43 return yield* Mac.key(Mac.hmacSha256, utf8(input.secret)).pipe(
44 Effect.flatMap((key) => mac.verify(key, tag.success, input.body)),
45 Effect.orElseSucceed(() => false),
46 );
47 },
48);