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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11243-a-stripe-signature-is-checked-by-one-pur → main 3 commits fork
4 files
changed +147
−0
scripts/unreached-allow.json +1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | [ |
| 2 | "src/billing/signature.ts", | |
| 2 | 3 | "src/crew/governor_log.ts", |
| 3 | 4 | "src/design/first_five_minutes.ts", |
| 4 | 5 | "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[] = [ | ||
| 72 | 72 | ]), |
| 73 | 73 | ...allow("setTimeout", `a shell session's resize debounce, ${PER_CONNECTION}`, [["src/machines/shell.ts", "() => session.size === resize"]]), |
| 74 | 74 | ...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\"))"]]), | |
| 75 | 76 | ...allow("setTimeout", `a media stream's idle deadline, ${PER_CONNECTION}`, [["src/git/read.ts", "() => { finish(); controller.error("]]), |
| 76 | 77 | ...allow("setTimeout", `a git upload's idle deadline, ${PER_CONNECTION}`, [["src/git/smart-http.ts", "stop"]]), |
| 77 | 78 | ...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"]]), |