Public: anyone can read this; the crew can change it.

A Stripe signature is checked by one pure function: good, bad, stale and rotated secrets each have a test #11253

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11243-a-stripe-signature-is-checked-by-one-pur → main 3 commits fork

  • Merged. scout/11243-a-stripe-signature-is-checked-by-one-pur was merged into main as 2937aef.

Review

Sign in to review this change.
4 files changed +147 −0
scripts/unreached-allow.json +1
@@ -1,4 +1,5 @@
11 [
2 "src/billing/signature.ts",
23 "src/crew/governor_log.ts",
34 "src/design/first_five_minutes.ts",
45 "src/flow/alarm_unproposed.ts",
src/billing/signature.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * A Stripe webhook signature, checked (#11243): one pure function, no I/O.
3 * Header `t=<unix>,v1=<hex>[,v1=<hex>…]`; the signed payload is `${t}.${rawBody}`,
4 * HMAC-SHA256 keyed by the `whsec_…` secret's UTF-8 bytes. Any `v1` may match
5 * (a rotated secret signs twice); other keys are ignored.
6 */
7 export type SignatureVerdict = "ok" | "bad" | "stale";
8
9 /** Seconds either side of `now` a timestamp may sit. */
10 export const TOLERANCE_SECONDS = 300;
11
12 const HEX = /^[0-9a-f]{64}$/i;
13
14 function fromHex(hex: string): Uint8Array {
15 const out = new Uint8Array(hex.length / 2);
16 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
17 return out;
18 }
19
20 /** Equal-length byte compare that does not stop at the first difference. */
21 function same(a: Uint8Array, b: Uint8Array): boolean {
22 if (a.length !== b.length) return false;
23 let diff = 0;
24 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
25 return diff === 0;
26 }
27
28 function parse(header: string): { t: number; v1: string[] } | null {
29 let t: number | null = null;
30 const v1: string[] = [];
31 for (const part of header.split(",")) {
32 const eq = part.indexOf("=");
33 if (eq < 0) continue;
34 const key = part.slice(0, eq).trim();
35 const value = part.slice(eq + 1).trim();
36 if (key === "t") {
37 if (!/^\d{1,12}$/.test(value)) return null;
38 if (t === null) t = Number(value);
39 } else if (key === "v1" && HEX.test(value)) v1.push(value);
40 }
41 return t === null || v1.length === 0 ? null : { t, v1 };
42 }
43
44 /** `now` is unix seconds. Malformed input is `bad`; a good signature too old or new is `stale`. */
45 export async function verifyStripeSignature(
46 rawBody: Uint8Array,
47 header: string,
48 secret: string,
49 now: number,
50 ): Promise<SignatureVerdict> {
51 if (typeof header !== "string" || typeof secret !== "string" || secret === "") return "bad";
52 const parsed = parse(header);
53 if (!parsed) return "bad";
54 const prefix = new TextEncoder().encode(`${parsed.t}.`);
55 const payload = new Uint8Array(prefix.length + rawBody.length);
56 payload.set(prefix, 0);
57 payload.set(rawBody, prefix.length);
58 const key = await crypto.subtle.importKey(
59 "raw",
60 new TextEncoder().encode(secret),
61 { name: "HMAC", hash: "SHA-256" },
62 false,
63 ["sign"],
64 );
65 const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, payload));
66 let match = false;
67 for (const candidate of parsed.v1) if (same(expected, fromHex(candidate))) match = true;
68 if (!match) return "bad";
69 return Math.abs(now - parsed.t) > TOLERANCE_SECONDS ? "stale" : "ok";
70 }
test/billing-signature.test.ts added +75
@@ -0,0 +1,75 @@
1 import { describe, expect, test } from "bun:test";
2 import { verifyStripeSignature } from "../src/billing/signature";
3
4 const secret = "whsec_test_secret";
5 const enc = new TextEncoder();
6 const NOW = 1_700_000_000;
7
8 async function sign(body: Uint8Array, t: number, key = secret): Promise<string> {
9 const k = await crypto.subtle.importKey("raw", enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
10 const payload = new Uint8Array([...enc.encode(`${t}.`), ...body]);
11 const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, payload));
12 return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
13 }
14
15 const body = enc.encode('{"id":"evt_1","type":"checkout.session.completed"}');
16
17 describe("verifyStripeSignature", () => {
18 test("good signature is ok, other header keys ignored", async () => {
19 const v1 = await sign(body, NOW);
20 expect(await verifyStripeSignature(body, `t=${NOW},v1=${v1},v0=abc,foo=bar`, secret, NOW)).toBe("ok");
21 });
22
23 test("wrong secret, altered body, or altered hex is bad", async () => {
24 const v1 = await sign(body, NOW, "whsec_other");
25 expect(await verifyStripeSignature(body, `t=${NOW},v1=${v1}`, secret, NOW)).toBe("bad");
26 const good = await sign(body, NOW);
27 expect(await verifyStripeSignature(enc.encode("{}"), `t=${NOW},v1=${good}`, secret, NOW)).toBe("bad");
28 expect(await verifyStripeSignature(body, `t=${NOW + 1},v1=${good}`, secret, NOW)).toBe("bad");
29 expect(await verifyStripeSignature(body, `t=${NOW},v1=${"0".repeat(64)}`, secret, NOW)).toBe("bad");
30 });
31
32 test("stale beyond 300 s either way, ok at the edge", async () => {
33 const old = NOW - 301;
34 expect(await verifyStripeSignature(body, `t=${old},v1=${await sign(body, old)}`, secret, NOW)).toBe("stale");
35 const future = NOW + 301;
36 expect(await verifyStripeSignature(body, `t=${future},v1=${await sign(body, future)}`, secret, NOW)).toBe("stale");
37 const edge = NOW - 300;
38 expect(await verifyStripeSignature(body, `t=${edge},v1=${await sign(body, edge)}`, secret, NOW)).toBe("ok");
39 });
40
41 test("a stale but forged signature is bad, not stale", async () => {
42 const old = NOW - 1000;
43 expect(await verifyStripeSignature(body, `t=${old},v1=${"1".repeat(64)}`, secret, NOW)).toBe("bad");
44 });
45
46 test("two v1 values: either may match (rotation)", async () => {
47 const old = await sign(body, NOW, "whsec_retired");
48 const cur = await sign(body, NOW);
49 expect(await verifyStripeSignature(body, `t=${NOW},v1=${old},v1=${cur}`, secret, NOW)).toBe("ok");
50 expect(await verifyStripeSignature(body, `t=${NOW},v1=${cur},v1=${old}`, secret, NOW)).toBe("ok");
51 expect(await verifyStripeSignature(body, `t=${NOW},v1=${old},v1=${old}`, secret, NOW)).toBe("bad");
52 });
53
54 test("malformed headers are bad", async () => {
55 const v1 = await sign(body, NOW);
56 for (const h of ["", "garbage", `v1=${v1}`, `t=${NOW}`, `t=abc,v1=${v1}`, `t=${NOW},v1=zz`, `t=${NOW},v1=${v1.slice(2)}`, `t=-5,v1=${v1}`]) {
57 expect(await verifyStripeSignature(body, h, secret, NOW)).toBe("bad");
58 }
59 expect(await verifyStripeSignature(body, `t=${NOW},v1=${v1}`, "", NOW)).toBe("bad");
60 });
61
62 test("signs the raw bytes, not the re-serialized JSON", async () => {
63 const raw = enc.encode('{ "a": 1, "b":[1, 2] }');
64 const reserialized = enc.encode(JSON.stringify(JSON.parse(new TextDecoder().decode(raw))));
65 expect(reserialized).not.toEqual(raw);
66 const v1 = await sign(raw, NOW);
67 expect(await verifyStripeSignature(raw, `t=${NOW},v1=${v1}`, secret, NOW)).toBe("ok");
68 expect(await verifyStripeSignature(reserialized, `t=${NOW},v1=${v1}`, secret, NOW)).toBe("bad");
69 });
70
71 test("non-UTF-8 bytes are signed as bytes", async () => {
72 const raw = new Uint8Array([0xff, 0xfe, 0x00, 0x7b]);
73 expect(await verifyStripeSignature(raw, `t=${NOW},v1=${await sign(raw, NOW)}`, secret, NOW)).toBe("ok");
74 });
75 });
test/timers-named.test.ts +1
@@ -72,6 +72,7 @@ const ALLOWED: readonly Allowed[] = [
7272 ]),
7373 ...allow("setTimeout", `a shell session's resize debounce, ${PER_CONNECTION}`, [["src/machines/shell.ts", "() => session.size === resize"]]),
7474 ...allow("setTimeout", `an ssh login's auth deadline, ${PER_CONNECTION}`, [["src/ssh/server.ts", "() => { this.transport.disconnect("]]),
75 ...allow("setTimeout", "a maintenance duty's deadline on one call: it rejects a promise and does no work on the loop", [["src/maintain/runner.ts", "() => reject(new Error(\"timed out\"))"]]),
7576 ...allow("setTimeout", `a media stream's idle deadline, ${PER_CONNECTION}`, [["src/git/read.ts", "() => { finish(); controller.error("]]),
7677 ...allow("setTimeout", `a git upload's idle deadline, ${PER_CONNECTION}`, [["src/git/smart-http.ts", "stop"]]),
7778 ...allow("setTimeout", "the loop-lag meter's own tick: it measures how late the loop runs, and a span around it would count that lateness as its own work", [["src/kernel/loop_lag.ts", "() => { // Early"]]),

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs