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

Adds `src/billing/signature.ts`, a pure `verifyStripeSignature(rawBody, header, secret, now)` that returns `ok`, `bad` or `stale`. Its eight tests are in `test/billing-signature.test.ts`. I ran those tests, `tsc --noEmit` and `scripts/gate.ts`, and they passed. I did not call `keep_plan`: it isn't among the tools I have here, so no plan is posted on the issue.

shipped production is serving it deploy abf00b0

opened this

Adds src/billing/signature.ts, a pure verifyStripeSignature(rawBody, header, secret, now) that returns ok, bad or stale. Its eight tests are in test/billing-signature.test.ts. I ran those tests, tsc --noEmit and scripts/gate.ts, and they passed. I did not call keep_plan: it isn't among the tools I have here, so no plan is posted on the issue.

What it checks

  • It parses t=<unix> and every v1=<64 hex>, ignores other keys, and returns bad if the header has no valid t or no valid v1, or if the secret is empty.
  • It computes HMAC-SHA256 through Web Crypto, keyed by the secret's UTF-8 bytes, over ${t}. followed by the raw body bytes.
  • It compares the result to every v1 with a constant-time byte compare, so either value matches during a secret rotation.
  • It returns stale only when a signature is valid and |now − t| > 300 s. A forged signature with an old timestamp is bad.
  • now is in unix seconds.

Tests

  • They cover good, bad (wrong secret, altered body, altered timestamp, altered hex), stale in both directions with 300 s still ok, and rotation with two v1 values in either order.
  • They also cover malformed headers, a body whose bytes differ from its re-serialized JSON (the re-serialized copy comes back bad), and non-UTF-8 bytes.

Left out No route, migration or dependency, as the issue scopes. Nothing calls the function yet; the webhook route belongs to a later issue in #11137.

Try it bun test test/billing-signature.test.ts

🤖 Generated with Claude Code

Closes #11243

commented

Adds src/billing/signature.ts, a pure verifyStripeSignature(rawBody, header, secret, now) that returns ok, bad or stale. Its eight tests are in test/billing-signature.test.ts. I ran those tests, tsc --noEmit and scripts/gate.ts, and they passed. I did not call keep_plan: it isn't among the tools I have here, so no plan is posted on the issue.

What it checks

  • It parses t=<unix> and every v1=<64 hex>, ignores other keys, and returns bad if the header has no valid t or no valid v1, or if the secret is empty.
  • It computes HMAC-SHA256 through Web Crypto, keyed by the secret's UTF-8 bytes, over ${t}. followed by the raw body bytes.
  • It compares the result to every v1 with a constant-time byte compare, so either value matches during a secret rotation.
  • It returns stale only when a signature is valid and |now − t| > 300 s. A forged signature with an old timestamp is bad.
  • now is in unix seconds.

Tests

  • They cover good, bad (wrong secret, altered body, altered timestamp, altered hex), stale in both directions with 300 s still ok, and rotation with two v1 values in either order.
  • They also cover malformed headers, a body whose bytes differ from its re-serialized JSON (the re-serialized copy comes back bad), and non-UTF-8 bytes.

Left out No route, migration or dependency, as the issue scopes. Nothing calls the function yet; the webhook route belongs to a later issue in #11137.

Try it bun test test/billing-signature.test.ts

🤖 Generated with Claude Code

commented

This pull adds verifyStripeSignature in src/billing/signature.ts, with tests in test/billing-signature.test.ts. The check failed on "every module under src/ is imported by another, or allowed" because nothing imports the new module yet. I added src/billing/signature.ts to scripts/unreached-allow.json. The issue scopes this to one module and one test file, with no route, so the allow-list is the way to mark it as not yet reached. test/unreached.test.ts and scripts/gate.ts now pass locally. I didn't re-run the full check; the forge runs it on this push.

The function takes the raw body bytes, the Stripe-Signature header, the whsec_… secret and now, and returns ok, bad or stale. Its eight tests cover good, bad, stale, rotation with two v1 values, malformed headers and raw bytes versus re-serialized JSON.

When the webhook route in #11137 imports it, that pull must remove the entry from the allow-list. The unreached test fails on a listed module that has become reached, so it will flag this if forgotten.

To try it: bun test test/billing-signature.test.ts test/unreached.test.ts

🤖 Generated with Claude Code

commented

This pull adds verifyStripeSignature in src/billing/signature.ts, with eight tests in test/billing-signature.test.ts. The function takes the raw body bytes, the Stripe-Signature header, the whsec_… secret and now, and returns ok, bad or stale. The tests cover good, bad, stale, two v1 values for rotation, malformed headers, and a body whose bytes differ from its re-serialized JSON.

Locally, test/timers-named.test.ts, test/red-main.test.ts, test/unreached.test.ts, test/billing-signature.test.ts and tsc --noEmit pass. The forge's full check on this push is still to come.

Two changes beyond the module and its test file, both needed to get the check green:

  • Unreached list: src/billing/signature.ts is in scripts/unreached-allow.json, because nothing imports it yet and the issue rules out a route. The webhook pull in #11137 must remove that entry when it imports the module, or test/unreached.test.ts fails.
  • Timer allow-list: the last check failed on a timer from base commit 4c33ea7f9 (#11228), not on this change. src/maintain/runner.ts has a deadline setTimeout outside during. I added it to the allow-list in test/timers-named.test.ts, the same way the other one-call deadlines are named. If another pull fixes it differently, drop my entry and keep theirs.

The other failure in that check, "a red main in another repository (#3992)", passes locally. I suspect it was a timing flake under load, but I haven't confirmed that.

To try it: bun test test/billing-signature.test.ts test/timers-named.test.ts test/unreached.test.ts

🤖 Generated with Claude Code

Approved

One pure, no-I/O verifier, as the issue scoped it. It uses Web Crypto HMAC with the secret as UTF-8 key bytes and signs the raw bytes. It compares in constant time against every v1. A forged signature with an old t is bad rather than stale, which is the right order. There is no route, no dependency and no README change, and no UI is touched. The preview has nothing to exercise, since nothing calls the function yet.

The unreached-allow entry is justified, and the pull says the webhook pull must remove it. The timers-named entry is outside the issue, but main lacks it and the maintain runner timer fails that check, so I accept it here.

Two things to do next:

  1. Add a test for a duplicate t= key and for a rotation where one v1 is stale. The code keeps the first t, and no test pins that.
  2. Have the #11137 webhook pull remove the unreached-allow entry and cap the body by the bytes actually read before it calls this function.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs