Public: anyone can read this; the crew can change it.
The replay window as its own thing: src/nonce.ts, one table and a sweep #27
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/19-the-replay-window-as-its-own-thing-src-n → main 1 commit fork
4 files
changed +155
−3
src/db.ts +7
| @@ -24,6 +24,13 @@ export const migrations: string[] = [ | ||
| 24 | 24 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 25 | 25 | )`, |
| 26 | 26 | `CREATE INDEX note_author ON note (author_id)`, |
| 27 | `CREATE TABLE nonce ( | |
| 28 | app_id TEXT NOT NULL, | |
| 29 | nonce TEXT NOT NULL, | |
| 30 | seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 31 | PRIMARY KEY (app_id, nonce) | |
| 32 | ) WITHOUT ROWID`, | |
| 33 | `CREATE INDEX nonce_seen_at ON nonce (seen_at)`, | |
| 27 | 34 | ]; |
| 28 | 35 | |
| 29 | 36 | export function openDb(dir = dataDir): Database { |
src/nonce.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { openDb } from "./db.ts"; | |
| 6 | import { remember, sweep, TTL_MS } from "./nonce.ts"; | |
| 7 | import { WINDOW_MS } from "./sign.ts"; | |
| 8 | ||
| 9 | const fresh = () => openDb(mkdtempSync(join(tmpdir(), "nonce-db-"))); | |
| 10 | ||
| 11 | test("the ttl outlives the signature window", () => { | |
| 12 | // Once a nonce is swept, a replay of that request is already `stale` | |
| 13 | // (past sign.ts's WINDOW_MS), so forgetting the nonce is safe. | |
| 14 | expect(TTL_MS).toBeGreaterThan(WINDOW_MS); | |
| 15 | }); | |
| 16 | ||
| 17 | test("the same nonce twice is a replay", () => { | |
| 18 | const db = fresh(); | |
| 19 | const now = 1_700_000_000_000; | |
| 20 | expect(remember(db, "torque", "n0nce-1234-ABCD", now)).toBe("fresh"); | |
| 21 | expect(remember(db, "torque", "n0nce-1234-ABCD", now)).toBe("replay"); | |
| 22 | }); | |
| 23 | ||
| 24 | test("two different apps may use the same nonce", () => { | |
| 25 | const db = fresh(); | |
| 26 | const now = 1_700_000_100_000; | |
| 27 | expect(remember(db, "torque", "shared-nonce-123", now)).toBe("fresh"); | |
| 28 | expect(remember(db, "brake", "shared-nonce-123", now)).toBe("fresh"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a nonce swept past the ttl is fresh again", () => { | |
| 32 | const db = fresh(); | |
| 33 | const t0 = 1_700_000_200_000; | |
| 34 | expect(remember(db, "torque", "old-nonce-abcd12", t0)).toBe("fresh"); | |
| 35 | const after = t0 + TTL_MS + 1_000; | |
| 36 | expect(sweep(db, after)).toBe(1); | |
| 37 | expect(remember(db, "torque", "old-nonce-abcd12", after)).toBe("fresh"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a malformed app id or nonce inserts nothing", () => { | |
| 41 | const db = fresh(); | |
| 42 | const now = 1_700_000_300_000; | |
| 43 | expect(remember(db, "Torque", "n0nce-1234-ABCD", now)).toBe("malformed"); | |
| 44 | expect(remember(db, "torque", "short", now)).toBe("malformed"); | |
| 45 | expect( | |
| 46 | db.query("SELECT count(*) AS n FROM nonce").get() as { n: number }, | |
| 47 | ).toEqual({ n: 0 }); | |
| 48 | }); | |
| 49 | ||
| 50 | test("the sweep of ten thousand rows is one statement", () => { | |
| 51 | const db = fresh(); | |
| 52 | const base = 1_700_001_000_000; | |
| 53 | const insert = db.query( | |
| 54 | "INSERT INTO nonce (app_id, nonce, seen_at) VALUES (?1, ?2, ?3)", | |
| 55 | ); | |
| 56 | db.transaction(() => { | |
| 57 | for (let i = 0; i < 10_000; i++) { | |
| 58 | insert.run( | |
| 59 | "torque", | |
| 60 | `bulk-nonce-${String(i).padStart(6, "0")}`, | |
| 61 | new Date(base - TTL_MS - 1_000).toISOString(), | |
| 62 | ); | |
| 63 | } | |
| 64 | })(); | |
| 65 | expect(sweep(db, base + TTL_MS * 2)).toBe(10_000); | |
| 66 | expect( | |
| 67 | (db.query("SELECT count(*) AS n FROM nonce").get() as { n: number }).n, | |
| 68 | ).toBe(0); | |
| 69 | }); | |
| 70 | ||
| 71 | test("remember called twice in a row on the same connection leaves one row", () => { | |
| 72 | const db = fresh(); | |
| 73 | const now = 1_700_002_000_000; | |
| 74 | remember(db, "torque", "twice-in-a-row-01", now); | |
| 75 | remember(db, "torque", "twice-in-a-row-01", now); | |
| 76 | expect( | |
| 77 | (db.query("SELECT count(*) AS n FROM nonce").get() as { n: number }).n, | |
| 78 | ).toBe(1); | |
| 79 | }); | |
| 80 | ||
| 81 | test("sweep only does the work at most once a minute per database", () => { | |
| 82 | const db = fresh(); | |
| 83 | const t0 = 1_700_003_000_000; | |
| 84 | remember(db, "torque", "throttle-nonce-01", t0 - TTL_MS - 1_000); | |
| 85 | expect(sweep(db, t0)).toBe(1); | |
| 86 | remember(db, "torque", "throttle-nonce-02", t0 - TTL_MS - 1_000); | |
| 87 | // Called again 30s later: too soon, so it skips the work. | |
| 88 | expect(sweep(db, t0 + 30_000)).toBe(0); | |
| 89 | // A full minute on, it runs again. | |
| 90 | expect(sweep(db, t0 + 60_000)).toBe(1); | |
| 91 | }); |
src/nonce.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The replay window: a nonce an app has already used, remembered long | |
| 3 | * enough that a repeat is caught, then forgotten. One table, keyed on | |
| 4 | * (app_id, nonce) so two apps never collide, `WITHOUT ROWID` because the | |
| 5 | * key is the whole row. `remember` is a single `INSERT ... ON CONFLICT DO | |
| 6 | * NOTHING` — never a read followed by a write — because the terminal is a | |
| 7 | * second process on the same file and two posts can race to claim the | |
| 8 | * same nonce at once. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { APP_RE, NONCE_RE } from "./sign.ts"; | |
| 13 | ||
| 14 | export type RememberResult = "fresh" | "replay" | "malformed"; | |
| 15 | ||
| 16 | /** Longer than sign.ts's 300s signature window: a swept nonce's request is | |
| 17 | * already `stale` by the time it could be replayed, so forgetting it is safe. */ | |
| 18 | export const TTL_MS = 600_000; | |
| 19 | const SWEEP_MS = 60_000; | |
| 20 | ||
| 21 | const iso = (ms: number): string => new Date(ms).toISOString(); | |
| 22 | ||
| 23 | /** Claims `nonce` for `appId`, or reports the claim already stands. */ | |
| 24 | export function remember( | |
| 25 | db: Database, | |
| 26 | appId: string, | |
| 27 | nonce: string, | |
| 28 | now = Date.now(), | |
| 29 | ): RememberResult { | |
| 30 | if (!APP_RE.test(appId) || !NONCE_RE.test(nonce)) return "malformed"; | |
| 31 | const { changes } = db | |
| 32 | .query( | |
| 33 | `INSERT INTO nonce (app_id, nonce, seen_at) VALUES (?1, ?2, ?3) | |
| 34 | ON CONFLICT DO NOTHING`, | |
| 35 | ) | |
| 36 | .run(appId, nonce, iso(now)); | |
| 37 | return changes > 0 ? "fresh" : "replay"; | |
| 38 | } | |
| 39 | ||
| 40 | const lastSwept = new WeakMap<Database, number>(); | |
| 41 | ||
| 42 | /** | |
| 43 | * Deletes nonces older than `ttlMs` and returns how many. Cheap enough to | |
| 44 | * call after every response: it keeps its own clock, keyed per database, | |
| 45 | * and only does the work at most once a minute no matter how often it is | |
| 46 | * called, so the caller never has to remember. | |
| 47 | */ | |
| 48 | export function sweep(db: Database, now = Date.now(), ttlMs = TTL_MS): number { | |
| 49 | const last = lastSwept.get(db) ?? -Infinity; | |
| 50 | if (now - last < SWEEP_MS) return 0; | |
| 51 | lastSwept.set(db, now); | |
| 52 | return db.query("DELETE FROM nonce WHERE seen_at < ?1").run(iso(now - ttlMs)) | |
| 53 | .changes; | |
| 54 | } |
src/sign.ts +3 −3
| @@ -33,14 +33,14 @@ export type Parts = { | ||
| 33 | 33 | |
| 34 | 34 | export type VerifyResult = "ok" | "bad_signature" | "stale" | "malformed"; |
| 35 | 35 | |
| 36 | const APP_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; | |
| 37 | const NONCE_RE = /^[A-Za-z0-9._-]{8,64}$/; | |
| 36 | export const APP_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; | |
| 37 | export const NONCE_RE = /^[A-Za-z0-9._-]{8,64}$/; | |
| 38 | 38 | const TIMESTAMP_RE = /^\d{1,15}$/; |
| 39 | 39 | // No leading slash omitted, no space, no control character (so no `\n`). |
| 40 | 40 | const PATH_RE = /^\/[\x21-\x7e]{0,255}$/; |
| 41 | 41 | // base64url of a 32-byte HMAC-SHA256 digest is always 43 characters. |
| 42 | 42 | const SIGNATURE_RE = /^[A-Za-z0-9_-]{43}$/; |
| 43 | const WINDOW_MS = 300_000; | |
| 43 | export const WINDOW_MS = 300_000; | |
| 44 | 44 | |
| 45 | 45 | function malformed(parts: Parts): boolean { |
| 46 | 46 | return ( |