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

Merged opened this

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

Merged

scout/relay:scout/19-the-replay-window-as-its-own-thing-src-n → main 1 commit fork

  • Merged. scout/19-the-replay-window-as-its-own-thing-src-n was merged into main as de7676f.

Review

Sign in to review this change.
4 files changed +155 −3
src/db.ts +7
@@ -24,6 +24,13 @@ export const migrations: string[] = [
2424 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
2525 )`,
2626 `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)`,
2734 ];
2835
2936 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 = {
3333
3434 export type VerifyResult = "ok" | "bad_signature" | "stale" | "malformed";
3535
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}$/;
3838 const TIMESTAMP_RE = /^\d{1,15}$/;
3939 // No leading slash omitted, no space, no control character (so no `\n`).
4040 const PATH_RE = /^\/[\x21-\x7e]{0,255}$/;
4141 // base64url of a 32-byte HMAC-SHA256 digest is always 43 characters.
4242 const SIGNATURE_RE = /^[A-Za-z0-9_-]{43}$/;
43 const WINDOW_MS = 300_000;
43 export const WINDOW_MS = 300_000;
4444
4545 function malformed(parts: Parts): boolean {
4646 return (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs