cursedbelt-server 4.24.1 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The master lock's guess throttle, CHARGED before the verify — and, on a Worker, in D1.
3
+ *
4
+ * ── 🔴 Why this file exists (task 2137, 2026-09-23) ─────────────────────────
5
+ * `MasterLock.unlock` used to read its failure count, run the argon2id verify (~0.3–1.4 s on a
6
+ * Worker), and write the count AFTER it. Every guess that arrived during one verify was judged
7
+ * against the same count — so a 12-wide burst was verified whole, where the schedule allows
8
+ * six before the first wait. On `collections`' Worker it was worse: the count lived in a JSON
9
+ * blob snapshotted per request, so the twelve each wrote `failures + 1` over one another and
10
+ * the burst was RECORDED as one guess. The same blob holds the open unlocks, so a failed guess
11
+ * settling after the owner's unlock could also drop his fresh session.
12
+ *
13
+ * `charge` is the one operation that closes it: check the window AND, when it is open, charge
14
+ * a failure in the same step, before anything slow happens. A correct verifier then `clear`s
15
+ * the run; the one outcome that was not a guess at all — every stored hash unparsable — is
16
+ * `refund`ed. Everything else, a verify that throws included, stays charged: fail closed.
17
+ *
18
+ * Two ledgers satisfy it:
19
+ * · the lock's own fields — synchronous, so atomic within one process (the Mac's daemons);
20
+ * · {@link createD1MasterLockAttempts} — one conditional UPSERT … RETURNING, atomic because D1
21
+ * runs one statement at a time, for a Worker whose lock is rebuilt on every request.
22
+ *
23
+ * A windowed refusal charges nothing — the delay throttles the RATE of guesses; it is not
24
+ * itself one (the same rule `apps/vault`'s unlock guard keeps).
25
+ */
26
+ import { delayAfter, windowRemaining } from "cursedbelt-core/master-lock";
27
+ import type { D1LikeDatabase } from "../d1/types.js";
28
+
29
+ /** What {@link MasterLockAttempts.charge} answers: admitted (and charged), or not (and not). */
30
+ export type MasterLockCharge = { admitted: true; failures: number } | { admitted: false; retryAfterMs: number };
31
+
32
+ export interface MasterLockAttempts {
33
+ /** What the ledger last knew, for the lock page's countdown. Never a decision. */
34
+ retryAfterMs(now: number): number;
35
+ /**
36
+ * 🔴 CHECK AND CHARGE in one step. Admitted → one failure is already charged, and
37
+ * `failures` counts it. Refused → nothing charged; `retryAfterMs` is the wait.
38
+ */
39
+ charge(now: number): MasterLockCharge | Promise<MasterLockCharge>;
40
+ /** The charged attempt was not a guess (nothing could be verified): take it back. */
41
+ refund(): void | Promise<void>;
42
+ /** A correct verifier: the run is over. */
43
+ clear(): void | Promise<void>;
44
+ }
45
+
46
+ /** The `meta`-style row a D1 ledger keeps its count in, beside the lock's record and state. */
47
+ export const MASTER_LOCK_ATTEMPTS_KEY = "master_lock:attempts";
48
+
49
+ /**
50
+ * `delayAfter` as a SQL `CASE` over a failure-count expression — GENERATED from the function,
51
+ * so the statement that admits a guess and the schedule the specs pin cannot disagree.
52
+ */
53
+ export function masterLockDelaySql(count: string): string {
54
+ const cap = delayAfter(Number.MAX_SAFE_INTEGER);
55
+ const arms: string[] = [];
56
+ for (let n = 0; delayAfter(n) < cap && n < 256; n++) arms.push(`WHEN ${count} <= ${n} THEN ${delayAfter(n)}`);
57
+ return `(CASE ${arms.join(" ")} ELSE ${cap} END)`;
58
+ }
59
+
60
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
61
+
62
+ /** One field of the stored JSON, 0 for a missing or unreadable row — never a throw that
63
+ * would leave the owner facing a lock nothing can open. */
64
+ const field = (column: string, name: string) =>
65
+ `COALESCE(CASE WHEN json_valid(${column}) THEN json_extract(${column}, '$.${name}') END, 0)`;
66
+
67
+ function parseCount(raw: string | null | undefined): { failures: number; lastFailureAt: number } {
68
+ try {
69
+ const parsed = JSON.parse(raw ?? "") as { failures?: unknown; lastFailureAt?: unknown };
70
+ const failures = Number(parsed.failures);
71
+ const lastFailureAt = Number(parsed.lastFailureAt);
72
+ return {
73
+ failures: Number.isFinite(failures) ? failures : 0,
74
+ lastFailureAt: Number.isFinite(lastFailureAt) ? lastFailureAt : 0,
75
+ };
76
+ } catch {
77
+ return { failures: 0, lastFailureAt: 0 };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The ledger over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) — `meta` by default,
83
+ * the table a Worker port already keeps the lock's record in, so it needs no migration.
84
+ *
85
+ * Its writes are AWAITED, never queued on a per-invocation collector: a charge that lands after
86
+ * the verify is the bug this exists to close. `snapshot` is the row as the request's own snapshot
87
+ * read it, for {@link MasterLockAttempts.retryAfterMs}; every decision reads D1 itself.
88
+ */
89
+ export function createD1MasterLockAttempts(
90
+ db: D1LikeDatabase,
91
+ options: { table?: string; key?: string; snapshot?: string | null } = {},
92
+ ): MasterLockAttempts {
93
+ const table = options.table ?? "meta";
94
+ if (!IDENTIFIER.test(table)) throw new Error(`master lock attempts: "${table}" is not a table name`);
95
+ const key = options.key ?? MASTER_LOCK_ATTEMPTS_KEY;
96
+ let known = parseCount(options.snapshot);
97
+ const value = `${table}.value`;
98
+
99
+ return {
100
+ retryAfterMs: (now) => windowRemaining(known.failures, known.lastFailureAt, now),
101
+
102
+ async charge(now) {
103
+ // ONE statement: the row is created at 1, or incremented only WHERE the window its own
104
+ // count implies has passed. A refused guess matches no row, so RETURNING is empty.
105
+ const charged = await db
106
+ .prepare(
107
+ `INSERT INTO ${table} (key, value) VALUES (?1, json_object('failures', 1, 'lastFailureAt', ?2))
108
+ ON CONFLICT(key) DO UPDATE SET value = json_object(
109
+ 'failures', ${field(value, "failures")} + 1, 'lastFailureAt', ?2)
110
+ WHERE ${field(value, "lastFailureAt")} + ${masterLockDelaySql(field(value, "failures"))} <= ?2
111
+ RETURNING value`,
112
+ )
113
+ .bind(key, now)
114
+ .first<{ value: string }>();
115
+ if (charged) {
116
+ known = parseCount(charged.value);
117
+ return { admitted: true, failures: known.failures };
118
+ }
119
+ const row = await db.prepare(`SELECT value FROM ${table} WHERE key = ?`).bind(key).first<{ value: string }>();
120
+ known = parseCount(row?.value);
121
+ return { admitted: false, retryAfterMs: Math.max(1, windowRemaining(known.failures, known.lastFailureAt, now)) };
122
+ },
123
+
124
+ async refund() {
125
+ await db
126
+ .prepare(
127
+ `UPDATE ${table} SET value = json_object(
128
+ 'failures', MAX(${field("value", "failures")} - 1, 0), 'lastFailureAt', ${field("value", "lastFailureAt")})
129
+ WHERE key = ?`,
130
+ )
131
+ .bind(key)
132
+ .run();
133
+ known = { ...known, failures: Math.max(0, known.failures - 1) };
134
+ },
135
+
136
+ async clear() {
137
+ await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run();
138
+ known = { failures: 0, lastFailureAt: 0 };
139
+ },
140
+ };
141
+ }
@@ -46,6 +46,11 @@ export {
46
46
  type MasterLockDirectoryOptions,
47
47
  masterLockPrincipalKey,
48
48
  } from "./principals.js";
49
+ export {
50
+ MASTER_LOCK_ATTEMPTS_KEY,
51
+ type MasterLockAttempts,
52
+ createD1MasterLockAttempts,
53
+ } from "./attempts.js";
49
54
  export {
50
55
  FIRST_ACCOUNT_ID,
51
56
  MasterLock,
@@ -57,6 +57,7 @@ import {
57
57
  isMasterLockKdfParams,
58
58
  windowRemaining,
59
59
  } from "cursedbelt-core/master-lock";
60
+ import type { MasterLockAttempts, MasterLockCharge } from "./attempts.js";
60
61
 
61
62
  /**
62
63
  * ONE master password, and therefore one TENANT of the app behind it.
@@ -179,6 +180,13 @@ export interface MasterLockOptions {
179
180
  * the store has nothing.
180
181
  */
181
182
  seedJson?: string | null;
183
+ /**
184
+ * Where the guess throttle is CHARGED. Omitted, it is this instance's own count — atomic
185
+ * within one process, which is all the Mac's daemons are. 🔴 Required wherever the lock is
186
+ * rebuilt per request (a Worker): pass `createD1MasterLockAttempts`, or K guesses landing
187
+ * inside one argon2id verify are judged against one stale count (task 2137, `./attempts.ts`).
188
+ */
189
+ attempts?: MasterLockAttempts;
182
190
  /** Injected clock. The tests drive it; production omits it. */
183
191
  now?: () => number;
184
192
  /**
@@ -265,10 +273,13 @@ export class MasterLock {
265
273
 
266
274
  private failures = 0;
267
275
  private lastFailureAt = 0;
276
+ /** See {@link MasterLockOptions.attempts}. */
277
+ private readonly attempts: MasterLockAttempts;
268
278
 
269
279
  constructor(options: MasterLockOptions) {
270
280
  this.store = options.store;
271
281
  this.stateStore = options.state ?? null;
282
+ this.attempts = options.attempts ?? this.ownAttempts();
272
283
  this.loadState();
273
284
  this.now = options.now ?? Date.now;
274
285
  this.onLockedChange = options.onLockedChange;
@@ -375,7 +386,7 @@ export class MasterLock {
375
386
  kdf: this.kdf,
376
387
  idleMs: this.idleMs,
377
388
  remainingMs: this.remainingMs,
378
- retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, this.now()),
389
+ retryAfterMs: this.attempts.retryAfterMs(this.now()),
379
390
  accountId: account?.id ?? null,
380
391
  accountLabel: account?.label ?? null,
381
392
  };
@@ -464,13 +475,16 @@ export class MasterLock {
464
475
  */
465
476
  async unlock(verifier: string): Promise<MasterLockUnlockResult> {
466
477
  const now = this.now();
467
- const retryAfterMs = windowRemaining(this.failures, this.lastFailureAt, now);
468
478
  const record = this.record;
479
+ // 🔴 CHARGED before anything slow happens — see `./attempts.ts`. A guess that lands while
480
+ // another is inside its argon2id verify sees that one already counted.
481
+ const charge: MasterLockCharge = await this.attempts.charge(now);
469
482
  // The equalizing verify happens first and unconditionally. `verifier` may be empty
470
483
  // or absurd; `verify` neither throws on that nor tells us anything, which is the point.
484
+ // A throw anywhere past the charge leaves it charged: the attempt fails closed.
471
485
  let opened: MasterLockAccount | null = null;
472
486
  let unparsable = 0;
473
- if (retryAfterMs === 0 && record) {
487
+ if (charge.admitted && record) {
474
488
  for (const account of record.accounts) {
475
489
  const genuine = await verifyArgon(verifier, account.verifierHash);
476
490
  // `null` is a hash this build cannot parse. It is NOT a wrong password, and
@@ -482,23 +496,50 @@ export class MasterLock {
482
496
  }
483
497
  await equalize(verifier);
484
498
 
485
- if (retryAfterMs > 0) return { ok: false, retryAfterMs };
499
+ if (!charge.admitted) return { ok: false, retryAfterMs: charge.retryAfterMs };
486
500
  if (!opened) {
487
501
  if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
502
+ // Nothing was verified, so nothing was guessed: the charge goes back.
503
+ await this.attempts.refund();
488
504
  this.log(
489
505
  "🔴 master lock: every stored verifier hash is unparsable — nothing can unlock",
490
506
  );
491
507
  return { ok: false, retryAfterMs: 0 };
492
508
  }
493
- this.failures += 1;
494
- this.lastFailureAt = now;
495
- this.saveState();
496
- return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
509
+ return { ok: false, retryAfterMs: windowRemaining(charge.failures, now, now) };
497
510
  }
498
511
 
512
+ await this.attempts.clear();
499
513
  return { ok: true, ...this.open(opened.id, now) };
500
514
  }
501
515
 
516
+ /**
517
+ * The default ledger: this instance's own fields, persisted with the live state. `charge`
518
+ * has no `await` between its check and its increment, so within one process it is atomic.
519
+ */
520
+ private ownAttempts(): MasterLockAttempts {
521
+ return {
522
+ retryAfterMs: (now) => windowRemaining(this.failures, this.lastFailureAt, now),
523
+ charge: (now) => {
524
+ const wait = windowRemaining(this.failures, this.lastFailureAt, now);
525
+ if (wait > 0) return { admitted: false, retryAfterMs: wait };
526
+ this.failures += 1;
527
+ this.lastFailureAt = now;
528
+ this.saveState();
529
+ return { admitted: true, failures: this.failures };
530
+ },
531
+ refund: () => {
532
+ this.failures = Math.max(0, this.failures - 1);
533
+ this.saveState();
534
+ },
535
+ clear: () => {
536
+ this.failures = 0;
537
+ this.lastFailureAt = 0;
538
+ this.saveState();
539
+ },
540
+ };
541
+ }
542
+
502
543
  /**
503
544
  * Mint a token for one account and start its idle clock. The one place a session is
504
545
  * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
@@ -566,6 +607,7 @@ export class MasterLock {
566
607
  createdAt: now,
567
608
  };
568
609
  this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
610
+ await this.attempts.clear();
569
611
  this.log("master lock: a first master password was chosen");
570
612
  return { ok: true, ...this.open(account.id, now) };
571
613
  }