cursedbelt-server 2.1.0 โ†’ 3.0.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.
@@ -48,6 +48,7 @@
48
48
  * one who has been guessing.
49
49
  */
50
50
  import {
51
+ type MasterLockAccountSummary,
51
52
  type MasterLockKdfParams,
52
53
  type MasterLockStatus,
53
54
  clampIdleMs,
@@ -55,16 +56,77 @@ import {
55
56
  windowRemaining,
56
57
  } from "cursedbelt-core/master-lock";
57
58
 
58
- /** The persisted record. Three public values โ€” none of them is a secret, and the two that
59
- * look like one are a salt and a one-way hash. */
59
+ /**
60
+ * ONE master password, and therefore one TENANT of the app behind it.
61
+ *
62
+ * ๐Ÿ”ด The id is the account's identity and it never changes โ€” not when the password is
63
+ * rotated, not when the label or the hint is rewritten. That is the property the whole
64
+ * model rests on, in the owner's words: *"If I change account 2 password it just changes
65
+ * how I get the access โ€ฆ I would still access the same content afterward."* An app stamps
66
+ * its rows with this id, so an id that moved would orphan a tenant's entire library.
67
+ */
68
+ export interface MasterLockAccount {
69
+ /** Stable, app-local, `[a-z0-9-]`. `acct-1` is the one a legacy record migrates to. */
70
+ id: string;
71
+ /** What the owner calls this tenant. Shown in the header once unlocked. */
72
+ label: string;
73
+ /**
74
+ * The owner's own reminder for this password, or `""`.
75
+ *
76
+ * Nobody โ€” not this server, not an agent, not the owner's own backups โ€” can recover a
77
+ * master password, so a forgotten one is a deleted tenant. The hint is the only
78
+ * recovery this design permits, and it is deliberately the owner's plain words rather
79
+ * than anything derived from the password.
80
+ */
81
+ hint: string;
82
+ /** `argon2id(verifier)`. The PHC string is self-describing, so there is no salt field. */
83
+ verifierHash: string;
84
+ createdAt: number;
85
+ }
86
+
87
+ /**
88
+ * The persisted record โ€” the app's KEYRING.
89
+ *
90
+ * โ”€โ”€ ๐Ÿ”ด ONE `kdf` for every account, and it is not an economy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
91
+ * Each account could carry its own salt. It must not, for three reasons, and the third is
92
+ * the security one:
93
+ *
94
+ * 1. **One derivation per attempt.** The browser runs 600,000 PBKDF2 iterations to turn a
95
+ * typed password into a verifier. Per-account salts would mean running that once per
96
+ * account before the server could say yes or no, so the lock page would get measurably
97
+ * slower every time the owner added a tenant.
98
+ * 2. **A rotation stays local.** `change` rewrites one account's `verifierHash` under
99
+ * these same params. A fresh salt would invalidate every sibling's stored hash at once.
100
+ * 3. **The page cannot be made to count the tenants.** `GET /__lock/status` has to publish
101
+ * the params before anyone has proved anything. With one descriptor it publishes one,
102
+ * whether there is a single account or ten โ€” so the lock screen is byte-identical in
103
+ * both cases. With per-account salts it would have to publish a list, and the length of
104
+ * that list is exactly the fact this feature exists to keep quiet.
105
+ *
106
+ * What it costs: somebody holding this record can spend one PBKDF2 chain per candidate
107
+ * password and test it against every account, instead of one chain per account. Each
108
+ * candidate still costs a separate argon2id per account. Against a 16-byte random salt that
109
+ * is not a meaningful saving, and it is the same trade the fleet already makes by seeding an
110
+ * app from the vault's own params.
111
+ */
60
112
  export interface MasterLockRecord {
61
113
  kdf: MasterLockKdfParams;
62
- /** `argon2id(verifier)`. The PHC string is self-describing, so there is no salt column. */
63
- verifierHash: string;
64
- /** The owner's configured idle timeout for THIS app. */
114
+ /** The owner's configured idle timeout for THIS app. One clock, all accounts. */
65
115
  idleMs: number;
116
+ /**
117
+ * Every master password this app answers to, oldest first.
118
+ *
119
+ * ๐Ÿ”ด EMPTY is a real and expected state โ€” a fresh app nobody has chosen a password for
120
+ * yet. It reads as LOCKED and offers enrollment; see {@link MasterLock.unlocked}.
121
+ */
122
+ accounts: MasterLockAccount[];
66
123
  }
67
124
 
125
+ /** The id a legacy single-password record migrates to. Fixed, because the app that adopts
126
+ * this stamps its existing rows with it โ€” a random id would orphan the library it is
127
+ * supposed to inherit. */
128
+ export const FIRST_ACCOUNT_ID = "acct-1";
129
+
68
130
  /**
69
131
  * Where the record rests. One blob, so an app can keep it in whatever it already has โ€”
70
132
  * binary-server's `settings` table, a satellite's `app_settings` row, a JSON file.
@@ -105,39 +167,37 @@ export interface MasterLockOptions {
105
167
  onLockedChange?: (locked: boolean) => void;
106
168
  /** Where to log the few things worth a line (a seed adopted, a rotation). */
107
169
  log?: (line: string) => void;
108
- /**
109
- * PER-PERSON mode. Inverts decision 5 below: with no record this lock reads as
110
- * LOCKED rather than open, and {@link MasterLock.enroll} is the way out.
111
- *
112
- * ๐Ÿ”ด It is an option and not the default because the two modes fail in opposite
113
- * directions, and each is right for exactly one situation:
114
- *
115
- * - **App-wide (`false`, the default).** One record for the whole app, seeded
116
- * from `MASTER_LOCK_SEED`. Unconfigured must read as OPEN, or a missing seed
117
- * bricks an app nobody can get into โ€” see `unlocked`.
118
- * - **Per-person (`true`).** One record per signed-in account, and there is no
119
- * seed: each person chooses their own. Unconfigured must read as LOCKED,
120
- * because "this person has not chosen a password yet" is the state of every
121
- * NEW account, and treating it as open would mean the wall admits everybody
122
- * it has never met โ€” the precise opposite of the feature.
123
- *
124
- * Nothing bricks in per-person mode, because enrollment is always available to
125
- * a principal with no record. That is what makes failing closed affordable here
126
- * and unaffordable there.
127
- */
128
- enrollable?: boolean;
129
170
  }
130
171
 
131
- /** What an unlock attempt answers. `ok` carries the token to set as a cookie. */
172
+ /**
173
+ * What an unlock attempt answers. `ok` carries the token to set as a cookie, and the
174
+ * ACCOUNT it opened.
175
+ *
176
+ * ๐Ÿ”ด The account id is the answer to "whose data do I serve now", and it comes from the
177
+ * password that was typed โ€” never from anything the caller sends. A refusal carries no
178
+ * account, and says nothing about which of them was closest.
179
+ */
132
180
  export type MasterLockUnlockResult =
133
- | { ok: true; token: string }
181
+ | { ok: true; token: string; accountId: string }
134
182
  | { ok: false; retryAfterMs: number };
135
183
 
136
184
  /** What choosing a FIRST master password answers. `ok` carries the unlock, so the
137
185
  * person who just set it is not asked to type it again in the next breath. */
138
186
  export type MasterLockEnrollResult =
139
- | { ok: true; token: string }
140
- | { ok: false; reason: "not-enrollable" | "already-configured" | "invalid" };
187
+ | { ok: true; token: string; accountId: string }
188
+ | { ok: false; reason: "already-configured" | "invalid" };
189
+
190
+ /** What adding a SECOND (or fifth) master password answers. It does NOT unlock into the
191
+ * new account โ€” the owner stays where they were until they type the new password. */
192
+ export type MasterLockAddAccountResult =
193
+ | { ok: true; account: MasterLockAccountSummary }
194
+ | { ok: false; reason: "wrong" | "invalid" | "duplicate" | "unconfigured" | "locked" };
195
+
196
+ /** One live unlock: the token's account, and when it last saw real interaction. */
197
+ interface MasterLockSession {
198
+ accountId: string;
199
+ lastActivityAt: number;
200
+ }
141
201
 
142
202
  const TOKEN_BYTES = 24;
143
203
 
@@ -156,8 +216,6 @@ export class MasterLock {
156
216
  private readonly now: () => number;
157
217
  private readonly onLockedChange: ((locked: boolean) => void) | undefined;
158
218
  private readonly log: (line: string) => void;
159
- /** Per-person mode โ€” see {@link MasterLockOptions.enrollable}. */
160
- private readonly enrollable: boolean;
161
219
 
162
220
  private record: MasterLockRecord | null;
163
221
  /**
@@ -177,7 +235,7 @@ export class MasterLock {
177
235
  * requirement true: *"then no one can see anything until the master password is
178
236
  * entered again"* โ€” one button, every device.
179
237
  */
180
- private sessions = new Map<string, number>();
238
+ private sessions = new Map<string, MasterLockSession>();
181
239
  private expiryTimer: ReturnType<typeof setTimeout> | null = null;
182
240
 
183
241
  private failures = 0;
@@ -188,7 +246,6 @@ export class MasterLock {
188
246
  this.now = options.now ?? Date.now;
189
247
  this.onLockedChange = options.onLockedChange;
190
248
  this.log = options.log ?? (() => undefined);
191
- this.enrollable = options.enrollable === true;
192
249
  this.record = readRecord(this.store.read());
193
250
  if (!this.record && options.seedJson) {
194
251
  const seeded = readRecord(options.seedJson);
@@ -204,9 +261,10 @@ export class MasterLock {
204
261
  }
205
262
  }
206
263
 
207
- /** Is a master password set at all? An app with `false` here refuses nothing. */
264
+ /** Is any master password set at all? An app with `false` here admits nobody, and
265
+ * offers the choose-a-password form instead. */
208
266
  get configured(): boolean {
209
- return this.record !== null;
267
+ return this.accounts.length > 0;
210
268
  }
211
269
 
212
270
  /** The params the browser must derive under, or null. Public by design. */
@@ -218,51 +276,71 @@ export class MasterLock {
218
276
  return this.record?.idleMs ?? clampIdleMs(undefined);
219
277
  }
220
278
 
221
- /** Is this lock in per-person mode? */
222
- get isEnrollable(): boolean {
223
- return this.enrollable;
279
+ /** Every account, oldest first. Empty on an app nobody has chosen a password for. */
280
+ get accounts(): readonly MasterLockAccount[] {
281
+ return this.record?.accounts ?? [];
224
282
  }
225
283
 
226
- /** May a first password be chosen right now? Per-person mode, and no record yet. */
284
+ /**
285
+ * May a first master password be chosen right now?
286
+ *
287
+ * ๐Ÿ”ด True whenever there is no account, on EVERY app โ€” there is no longer a mode that
288
+ * decides this. The owner's ruling, on what an app with no master password should do:
289
+ * *"if there is no master password then it has to get one set before a user could add
290
+ * anything. It would be booting to nothing until the password is created."*
291
+ */
227
292
  get awaitingEnrollment(): boolean {
228
- return this.enrollable && this.record === null;
293
+ return this.accounts.length === 0;
229
294
  }
230
295
 
231
296
  /**
232
- * Is the site open right now?
233
- *
234
- * ๐Ÿ”ด An UNCONFIGURED app-wide lock reads as UNLOCKED, and that is the deliberate
235
- * choice. A lock with no password cannot be opened by anybody, including the owner โ€”
236
- * failing closed would brick an app rather than protect it, and this guards a UI whose
237
- * data is already behind the app's own owner-only auth. `/healthz` reports
238
- * `configured` so a smoke can catch the state externally instead of the owner
239
- * discovering it.
240
- *
241
- * ๐Ÿ”ด In PER-PERSON mode the same reasoning runs the other way and this reads as
242
- * LOCKED. Nobody is bricked โ€” {@link enroll} is open to a principal with no record โ€”
243
- * and "has not chosen a password yet" describes every new account, so opening for it
244
- * would admit exactly the people the wall exists to stop. See
245
- * {@link MasterLockOptions.enrollable}.
297
+ * Is anybody's unlock live right now?
298
+ *
299
+ * ๐Ÿ”ด An UNCONFIGURED lock reads as LOCKED, and that INVERTS what this used to do.
300
+ * App-wide, "no password means open" was the right call while the lock only decided
301
+ * whether a UI was visible: failing closed would have bricked an app rather than
302
+ * protected it, since nobody could type a password that did not exist. Both halves of
303
+ * that reasoning are now gone. {@link enroll} means an app with no password offers to
304
+ * take one, so nothing bricks; and the password now decides WHICH TENANT'S DATA the
305
+ * app serves, so "open with no password" has no coherent answer to give โ€” there is no
306
+ * account whose rows it could show. The owner ruled the same way: *"It would be
307
+ * booting to nothing until the password is created."*
308
+ *
309
+ * ๐Ÿ”ด And this is a HEALTH reading, not an authorization one. Nothing may admit a
310
+ * request because `unlocked` is true โ€” see {@link accountFor}, which is what the guard
311
+ * asks. The two were the same question only while an app had one tenant.
246
312
  */
247
313
  get unlocked(): boolean {
248
- if (!this.record) return !this.enrollable;
314
+ if (!this.configured) return false;
249
315
  this.sweep();
250
316
  return this.sessions.size > 0;
251
317
  }
252
318
 
253
- /** Milliseconds of idleness left before the unlock lapses; 0 when locked. */
254
319
  /** Milliseconds of idleness left before the LONGEST-lived unlock lapses; 0 when none
255
320
  * is live. The status page counts down with it, so it is the most generous of the
256
321
  * open devices rather than an arbitrary one. */
257
322
  get remainingMs(): number {
258
- if (!this.record) return 0;
323
+ const record = this.record;
324
+ if (!record) return 0;
259
325
  this.sweep();
260
326
  if (this.sessions.size === 0) return 0;
261
- const newest = Math.max(...this.sessions.values());
262
- return Math.max(0, newest + this.record.idleMs - this.now());
327
+ let newest = 0;
328
+ for (const session of this.sessions.values()) {
329
+ if (session.lastActivityAt > newest) newest = session.lastActivityAt;
330
+ }
331
+ return Math.max(0, newest + record.idleMs - this.now());
263
332
  }
264
333
 
265
- status(): MasterLockStatus {
334
+ /**
335
+ * The state, for the lock page and for the app's own header.
336
+ *
337
+ * `req` is optional only so a health check can ask without one. Pass it wherever there
338
+ * IS a request: without it the reply carries no account, and a client would read that
339
+ * as "locked" while holding a perfectly good unlock.
340
+ */
341
+ status(req?: Request): MasterLockStatus {
342
+ const accountId = req ? this.accountFor(req) : null;
343
+ const account = accountId ? this.accountById(accountId) : null;
266
344
  return {
267
345
  configured: this.configured,
268
346
  enrollable: this.awaitingEnrollment,
@@ -271,30 +349,91 @@ export class MasterLock {
271
349
  idleMs: this.idleMs,
272
350
  remainingMs: this.remainingMs,
273
351
  retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, this.now()),
352
+ accountId: account?.id ?? null,
353
+ accountLabel: account?.label ?? null,
274
354
  };
275
355
  }
276
356
 
277
- /** Does this request carry the live unlock? The cookie, or the header for a caller with
278
- * no cookie jar. */
279
- presents(req: Request): boolean {
357
+ /**
358
+ * ๐Ÿ”ด **WHICH account this request's unlock belongs to** โ€” the one authorization
359
+ * question this class answers, and the one the guard asks.
360
+ *
361
+ * `null` means "no live unlock is presented here", which is the only thing that may
362
+ * ever refuse a request. It deliberately does not fall back to {@link unlocked}: that
363
+ * reads "somebody's device is open", and honouring it would make the unlock AMBIENT โ€”
364
+ * a second browser signed in as the owner would walk through having typed nothing, and
365
+ * would then be served whichever tenant happened to be open. While an app had one
366
+ * password that was merely sloppy; with several it hands the wrong library to the
367
+ * wrong session.
368
+ *
369
+ * The token is matched in constant time against every live one, and deliberately
370
+ * WITHOUT a `Map.get` shortcut: a hash lookup on attacker-supplied bytes leaks by
371
+ * timing what the comparison is written to hide.
372
+ */
373
+ accountFor(req: Request): string | null {
280
374
  this.sweep();
281
- if (this.sessions.size === 0) return false;
282
- // Compared against every live token in constant time, and deliberately WITHOUT a
283
- // `Map.has` shortcut: a hash lookup on attacker-supplied bytes leaks by timing what
284
- // the comparison is written to hide.
375
+ if (this.sessions.size === 0) return null;
376
+ let found: string | null = null;
285
377
  for (const presented of presentedTokens(req)) {
286
- for (const live of this.sessions.keys()) {
287
- if (constantTimeEqual(presented, live)) return true;
378
+ for (const [live, session] of this.sessions) {
379
+ // No early `return`: every candidate is compared against every live token so
380
+ // the loop's duration does not say which cookie in the jar was the right one.
381
+ if (constantTimeEqual(presented, live)) found ??= session.accountId;
288
382
  }
289
383
  }
290
- return false;
384
+ return found;
385
+ }
386
+
387
+ /** Does this request carry a live unlock? The cookie, or the header for a caller with
388
+ * no cookie jar. A thin reading of {@link accountFor}, which is the real answer. */
389
+ presents(req: Request): boolean {
390
+ return this.accountFor(req) !== null;
391
+ }
392
+
393
+ /** One account by id, or null. */
394
+ accountById(id: string): MasterLockAccount | null {
395
+ return this.accounts.find((account) => account.id === id) ?? null;
291
396
  }
292
397
 
293
398
  /**
294
- * Evaluate a verifier and, on a match, mint the unlock.
399
+ * The hints, readable WHILE LOCKED โ€” the owner's *"let me setup hints for them"*.
295
400
  *
296
- * Runs an argon2 verify on EVERY path โ€” no record, throttled, wrong โ€” so the answer's
297
- * latency says nothing the answer itself does not.
401
+ * ๐Ÿ”ด This is the one place that discloses how many accounts exist, and it is a
402
+ * separate route for exactly that reason (see `MASTER_LOCK_PATHS.hints`). Everyone who
403
+ * can reach it has already passed the app's own owner-only sign-in, and the lock page
404
+ * asks for it only when somebody clicks "I forgot" โ€” so the default screen stays
405
+ * byte-identical whether this app has one tenant or ten.
406
+ */
407
+ hints(): { label: string; hint: string }[] {
408
+ return this.accounts.map((account) => ({ label: account.label, hint: account.hint }));
409
+ }
410
+
411
+ /** Every account, for the management UI. Never a hash, and never anything derived from
412
+ * a password. `current` marks the one the caller's own unlock opened. */
413
+ summaries(req: Request): MasterLockAccountSummary[] {
414
+ const current = this.accountFor(req);
415
+ return this.accounts.map((account) => ({
416
+ id: account.id,
417
+ label: account.label,
418
+ hint: account.hint,
419
+ createdAt: account.createdAt,
420
+ current: account.id === current,
421
+ }));
422
+ }
423
+
424
+ /**
425
+ * Evaluate a verifier and, on a match, mint the unlock for the account that matched.
426
+ *
427
+ * Runs an argon2 verify on EVERY path โ€” no accounts, throttled, wrong โ€” so the
428
+ * answer's latency says nothing the answer itself does not.
429
+ *
430
+ * ๐Ÿ”ด **Every account is tried, and the loop does not stop at the first match.**
431
+ * Stopping early is the obvious optimisation and it is a timing oracle: a password
432
+ * matching the first account would answer in one argon2id and a wrong one in N, so the
433
+ * shape of the reply would say how many tenants exist and roughly where in the list
434
+ * this one sits. The cost is bounded by how many passwords the owner has made โ€” single
435
+ * digits โ€” and every outcome now costs the same. (`apps/collections` reached the same
436
+ * conclusion for its per-file keys in 2026-08-14; this is that reasoning, kept.)
298
437
  */
299
438
  async unlock(verifier: string): Promise<MasterLockUnlockResult> {
300
439
  const now = this.now();
@@ -302,86 +441,201 @@ export class MasterLock {
302
441
  const record = this.record;
303
442
  // The equalizing verify happens first and unconditionally. `verifier` may be empty
304
443
  // or absurd; `verify` neither throws on that nor tells us anything, which is the point.
305
- const genuine =
306
- retryAfterMs === 0 && record ? await verifyArgon(verifier, record.verifierHash) : null;
444
+ let opened: MasterLockAccount | null = null;
445
+ let unparsable = 0;
446
+ if (retryAfterMs === 0 && record) {
447
+ for (const account of record.accounts) {
448
+ const genuine = await verifyArgon(verifier, account.verifierHash);
449
+ // `null` is a hash this build cannot parse. It is NOT a wrong password, and
450
+ // saying so is what stops the owner re-typing a correct password forever โ€”
451
+ // the exact day `apps/collections` lost to a quoted hash in its secrets file.
452
+ if (genuine === null) unparsable += 1;
453
+ else if (genuine) opened ??= account;
454
+ }
455
+ }
307
456
  await equalize(verifier);
308
457
 
309
458
  if (retryAfterMs > 0) return { ok: false, retryAfterMs };
310
- // `null` is a hash this build cannot parse. It is NOT a wrong password, and saying so
311
- // is what stops the owner re-typing a correct password forever โ€” the exact day
312
- // `apps/collections` lost to a quoted hash in its secrets file.
313
- if (genuine === null) {
314
- if (record) this.log("๐Ÿ”ด master lock: the stored verifier hash cannot be parsed โ€” nothing can unlock");
315
- return { ok: false, retryAfterMs: 0 };
316
- }
317
- if (!genuine) {
459
+ if (!opened) {
460
+ if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
461
+ this.log(
462
+ "๐Ÿ”ด master lock: every stored verifier hash is unparsable โ€” nothing can unlock",
463
+ );
464
+ return { ok: false, retryAfterMs: 0 };
465
+ }
318
466
  this.failures += 1;
319
467
  this.lastFailureAt = now;
320
468
  return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
321
469
  }
322
470
 
471
+ return { ok: true, ...this.open(opened.id, now) };
472
+ }
473
+
474
+ /**
475
+ * Mint a token for one account and start its idle clock. The one place a session is
476
+ * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
477
+ */
478
+ private open(accountId: string, now: number): { token: string; accountId: string } {
323
479
  this.failures = 0;
324
480
  this.lastFailureAt = 0;
325
481
  const token = randomToken();
326
482
  const wasLocked = this.sessions.size === 0;
327
483
  // ADDED, never replacing: the desk and the phone are the same person and both stay
328
484
  // open. See the `sessions` note.
329
- this.sessions.set(token, now);
485
+ this.sessions.set(token, { accountId, lastActivityAt: now });
330
486
  this.scheduleExpiry();
331
487
  if (wasLocked) this.onLockedChange?.(false);
332
- return { ok: true, token };
488
+ return { token, accountId };
333
489
  }
334
490
 
335
491
  /**
336
- * Choose the FIRST master password for this lock โ€” per-person mode only.
492
+ * Choose the FIRST master password for this app, creating its first tenant.
337
493
  *
338
494
  * โ”€โ”€ Why this is not a hole โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
339
- * It looks like "anyone who can reach the endpoint sets the password", and the three
340
- * things that make it not are all checked here rather than at the caller:
341
- *
342
- * 1. **It refuses once a record exists.** There is no overwrite branch and no force
343
- * flag; rotation is {@link change}, which demands the current password re-typed.
344
- * So this can only ever create the password that was never set.
345
- * 2. **It refuses outside per-person mode**, so mounting the route in an app-wide
346
- * app cannot silently arm a lock the owner did not choose.
347
- * 3. **The principal is resolved from the app's own session, not from the request
348
- * body.** That is the guard's job (`resolve`), and it is why a stranger cannot
349
- * enroll on somebody else's behalf: to reach this at all they must already hold a
350
- * live signed-in session for the account they are enrolling, and if they hold
351
- * that, the master lock was never what was standing between them and the app.
352
- *
353
- * The unlock is minted with the record, deliberately: being asked to type a password
495
+ * It looks like "anyone who can reach the endpoint sets the password", and the two
496
+ * things that make it not are checked here rather than at the caller:
497
+ *
498
+ * 1. **It refuses once any account exists.** There is no overwrite branch and no
499
+ * force flag; rotation is {@link change}, which demands the current password
500
+ * re-typed, and a second tenant is {@link addAccount}, which demands the same. So
501
+ * this can only ever create the password that was never set.
502
+ * 2. **Whoever reaches it is already inside the building.** The guard runs behind the
503
+ * app's own sign-in, so to enroll at all a caller must hold a live session for an
504
+ * account the app admits โ€” and if they hold that, the master lock was never what
505
+ * was standing between them and the app.
506
+ *
507
+ * ๐Ÿ”ด It is no longer gated on a per-person MODE. The owner's ruling is that an app
508
+ * with no master password boots to nothing until one is created, so the form is the
509
+ * way out of that state on every app โ€” see {@link awaitingEnrollment}.
510
+ *
511
+ * The unlock is minted with the account, deliberately: being asked to type a password
354
512
  * back at the person who just chose it, twice, teaches them the app is broken.
355
513
  */
356
514
  async enroll(input: {
357
515
  kdf: MasterLockKdfParams;
358
516
  verifier: string;
517
+ label?: string;
518
+ hint?: string;
359
519
  }): Promise<MasterLockEnrollResult> {
360
- if (!this.enrollable) return { ok: false, reason: "not-enrollable" };
361
520
  // Re-read the store rather than trusting the field: two tabs can submit the
362
521
  // choose-a-password form at once, and the second must lose rather than replace.
363
- if (this.record || readRecord(this.store.read())) {
364
- this.record ??= readRecord(this.store.read());
522
+ const stored = readRecord(this.store.read());
523
+ if (stored && stored.accounts.length > 0) {
524
+ this.record = stored;
365
525
  return { ok: false, reason: "already-configured" };
366
526
  }
527
+ if (this.configured) return { ok: false, reason: "already-configured" };
367
528
  if (!isMasterLockKdfParams(input.kdf) || typeof input.verifier !== "string" || !input.verifier) {
368
529
  return { ok: false, reason: "invalid" };
369
530
  }
370
- const next: MasterLockRecord = {
371
- kdf: input.kdf,
531
+ const now = this.now();
532
+ const account: MasterLockAccount = {
533
+ id: FIRST_ACCOUNT_ID,
534
+ label: cleanLabel(input.label) || "Account 1",
535
+ hint: cleanHint(input.hint),
372
536
  verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
373
- idleMs: clampIdleMs(undefined),
537
+ createdAt: now,
374
538
  };
375
- this.record = next;
376
- this.store.write(JSON.stringify(next));
377
- this.failures = 0;
378
- this.lastFailureAt = 0;
379
- const token = randomToken();
380
- this.sessions.set(token, this.now());
381
- this.scheduleExpiry();
382
- this.onLockedChange?.(false);
539
+ this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
383
540
  this.log("master lock: a first master password was chosen");
384
- return { ok: true, token };
541
+ return { ok: true, ...this.open(account.id, now) };
542
+ }
543
+
544
+ /**
545
+ * Add ANOTHER master password, and with it another tenant.
546
+ *
547
+ * The owner's model, in his words: *"The master passwords represent their own tenants
548
+ * or you can think of it as a fully separate account even though I am the user in both
549
+ * cases โ€ฆ If I login with a master password I only see the files I added or removed
550
+ * with that login."*
551
+ *
552
+ * โ”€โ”€ The three refusals, and why each one is here โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
553
+ *
554
+ * 1. **No live unlock โ†’ refused.** Minting a tenant is an action from INSIDE the app,
555
+ * not a way into it.
556
+ * 2. **The current password, re-typed.** An open session proves somebody is at the
557
+ * keyboard, not that it is the owner โ€” the same reasoning {@link change} runs on,
558
+ * and creating a hidden second library is at least as consequential as a rotation.
559
+ * 3. ๐Ÿ”ด **A password that already opens something is refused.** Two accounts sharing
560
+ * one password would make {@link unlock} serve whichever the loop reached first,
561
+ * so the owner would type the password they have always typed and be handed an
562
+ * empty library โ€” with their real one apparently gone. It costs one argon2id per
563
+ * existing account, at creation time only.
564
+ *
565
+ * It does NOT unlock into the new account. Switching tenants means typing that
566
+ * tenant's password, which is the whole boundary.
567
+ */
568
+ async addAccount(
569
+ req: Request,
570
+ input: { currentVerifier: string; verifier: string; label: string; hint?: string },
571
+ ): Promise<MasterLockAddAccountResult> {
572
+ const record = this.record;
573
+ if (!record || record.accounts.length === 0) return { ok: false, reason: "unconfigured" };
574
+ const currentId = this.accountFor(req);
575
+ if (!currentId) return { ok: false, reason: "locked" };
576
+ const current = this.accountById(currentId);
577
+ if (!current) return { ok: false, reason: "locked" };
578
+
579
+ const label = cleanLabel(input.label);
580
+ if (!label || typeof input.verifier !== "string" || !input.verifier) {
581
+ return { ok: false, reason: "invalid" };
582
+ }
583
+
584
+ const genuine = await verifyArgon(input.currentVerifier, current.verifierHash);
585
+ await equalize(input.currentVerifier);
586
+ if (genuine === null) return { ok: false, reason: "unconfigured" };
587
+ if (!genuine) return { ok: false, reason: "wrong" };
588
+
589
+ for (const account of record.accounts) {
590
+ if (await verifyArgon(input.verifier, account.verifierHash)) {
591
+ return { ok: false, reason: "duplicate" };
592
+ }
593
+ }
594
+
595
+ const now = this.now();
596
+ const account: MasterLockAccount = {
597
+ id: nextAccountId(record.accounts),
598
+ label,
599
+ hint: cleanHint(input.hint),
600
+ verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
601
+ createdAt: now,
602
+ };
603
+ this.write({ ...record, accounts: [...record.accounts, account] });
604
+ this.log(`master lock: a new account was created (${account.id})`);
605
+ return {
606
+ ok: true,
607
+ account: {
608
+ id: account.id,
609
+ label: account.label,
610
+ hint: account.hint,
611
+ createdAt: account.createdAt,
612
+ current: false,
613
+ },
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Rename an account or rewrite its hint. Never its password โ€” that is {@link change}.
619
+ *
620
+ * ๐Ÿ”ด Only the account the caller's own unlock opened. Editing a sibling would let a
621
+ * session that holds one tenant's password rewrite another tenant's hint, which is the
622
+ * one field designed to be read by somebody who is locked out.
623
+ */
624
+ editAccount(req: Request, input: { id: string; label?: string; hint?: string }): boolean {
625
+ const record = this.record;
626
+ const currentId = this.accountFor(req);
627
+ if (!record || !currentId || currentId !== input.id) return false;
628
+ const accounts = record.accounts.map((account) =>
629
+ account.id === input.id
630
+ ? {
631
+ ...account,
632
+ label: input.label === undefined ? account.label : cleanLabel(input.label) || account.label,
633
+ hint: input.hint === undefined ? account.hint : cleanHint(input.hint),
634
+ }
635
+ : account,
636
+ );
637
+ this.write({ ...record, accounts });
638
+ return true;
385
639
  }
386
640
 
387
641
  /**
@@ -396,16 +650,19 @@ export class MasterLock {
396
650
  // Slides only the token that was presented. Two devices idle independently, so the
397
651
  // phone left on the counter locks on its own schedule while the desk stays open โ€”
398
652
  // which is decision 2 applied per device rather than per person.
653
+ let slid = false;
399
654
  for (const presented of presentedTokens(req)) {
400
- for (const live of this.sessions.keys()) {
655
+ for (const [live, session] of this.sessions) {
656
+ // No early `return`, for the reason `accountFor` gives: the loop's duration
657
+ // must not say which of the jar's cookies was this app's.
401
658
  if (constantTimeEqual(presented, live)) {
402
- this.sessions.set(live, this.now());
403
- this.scheduleExpiry();
404
- return true;
659
+ this.sessions.set(live, { ...session, lastActivityAt: this.now() });
660
+ slid = true;
405
661
  }
406
662
  }
407
663
  }
408
- return false;
664
+ if (slid) this.scheduleExpiry();
665
+ return slid;
409
666
  }
410
667
 
411
668
  /** The manual lock โ€” the owner's *"I want a lock option to turn the site back to needing
@@ -421,39 +678,62 @@ export class MasterLock {
421
678
  }
422
679
 
423
680
  /**
424
- * Rotate this app's master password. Requires the CURRENT one, re-typed โ€” an open session
425
- * proves somebody is at the keyboard, not that it is the owner, and the whole feature
426
- * exists for the minutes when it is not.
681
+ * Rotate ONE account's master password โ€” the account the caller's own unlock opened.
682
+ *
683
+ * Requires the current one, re-typed: an open session proves somebody is at the
684
+ * keyboard, not that it is the owner, and the whole feature exists for the minutes when
685
+ * it is not.
427
686
  *
428
- * Per-app by construction: the record this writes is this app's own. The owner asked for
429
- * exactly that, and for there to be no way to change them all at once.
687
+ * ๐Ÿ”ด **The account id does not move, so the tenant's library does not move.** That is
688
+ * the owner's requirement stated as code: *"If I change account 2 password it just
689
+ * changes how I get the access. For instance I could change it from 'password' to
690
+ * 'passwordIPicked' later and I would still access the same content afterward."*
691
+ *
692
+ * ๐Ÿ”ด **No new KDF params.** They are shared by every account on this app, so minting a
693
+ * fresh salt here would invalidate every SIBLING's stored hash โ€” rotating account 2
694
+ * would lock the owner out of account 1, silently, with no way back. See
695
+ * {@link MasterLockRecord}.
430
696
  */
431
- async change(input: {
432
- currentVerifier: string;
433
- kdf: MasterLockKdfParams;
434
- verifier: string;
435
- }): Promise<{ ok: true } | { ok: false; reason: "wrong" | "invalid" | "unconfigured" }> {
697
+ async change(
698
+ req: Request,
699
+ input: { currentVerifier: string; verifier: string },
700
+ ): Promise<{ ok: true } | { ok: false; reason: "wrong" | "invalid" | "unconfigured" | "locked" }> {
436
701
  const record = this.record;
437
- if (!record) return { ok: false, reason: "unconfigured" };
438
- if (!isMasterLockKdfParams(input.kdf) || typeof input.verifier !== "string" || !input.verifier) {
702
+ if (!record || record.accounts.length === 0) return { ok: false, reason: "unconfigured" };
703
+ const currentId = this.accountFor(req);
704
+ if (!currentId) return { ok: false, reason: "locked" };
705
+ const current = this.accountById(currentId);
706
+ if (!current) return { ok: false, reason: "locked" };
707
+ if (typeof input.verifier !== "string" || !input.verifier) {
439
708
  return { ok: false, reason: "invalid" };
440
709
  }
441
- const genuine = await verifyArgon(input.currentVerifier, record.verifierHash);
710
+ const genuine = await verifyArgon(input.currentVerifier, current.verifierHash);
442
711
  await equalize(input.currentVerifier);
443
712
  if (genuine === null) return { ok: false, reason: "unconfigured" };
444
713
  if (!genuine) return { ok: false, reason: "wrong" };
445
714
 
446
- const next: MasterLockRecord = {
447
- kdf: input.kdf,
448
- verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
449
- idleMs: record.idleMs,
450
- };
451
- this.record = next;
452
- this.store.write(JSON.stringify(next));
715
+ // ๐Ÿ”ด Refused when the NEW password already opens a sibling, for the reason
716
+ // `addAccount` gives at length: two accounts under one password make `unlock` serve
717
+ // whichever the loop reaches first, and the owner would find the wrong library.
718
+ for (const account of record.accounts) {
719
+ if (account.id === current.id) continue;
720
+ if (await verifyArgon(input.verifier, account.verifierHash)) {
721
+ return { ok: false, reason: "invalid" };
722
+ }
723
+ }
724
+
725
+ const verifierHash = await Bun.password.hash(input.verifier, { algorithm: "argon2id" });
726
+ this.write({
727
+ ...record,
728
+ accounts: record.accounts.map((account) =>
729
+ account.id === current.id ? { ...account, verifierHash } : account,
730
+ ),
731
+ });
453
732
  // Every open page dies with the old password โ€” otherwise rotating it after a bad day
454
- // leaves the session that worried you still holding the door.
733
+ // leaves the session that worried you still holding the door. ALL of them, including
734
+ // the other accounts': "lock this app" has never meant "lock part of it".
455
735
  this.lock();
456
- this.log("master lock: master password rotated for this app");
736
+ this.log(`master lock: password rotated for ${current.id}`);
457
737
  return { ok: true };
458
738
  }
459
739
 
@@ -462,12 +742,18 @@ export class MasterLock {
462
742
  const record = this.record;
463
743
  const idleMs = clampIdleMs(value);
464
744
  if (!record) return idleMs;
465
- this.record = { ...record, idleMs };
466
- this.store.write(JSON.stringify(this.record));
745
+ this.write({ ...record, idleMs });
467
746
  this.scheduleExpiry();
468
747
  return idleMs;
469
748
  }
470
749
 
750
+ /** Persist the record and keep the field in step. One place, so no path can write the
751
+ * store and forget the copy this process is answering from. */
752
+ private write(record: MasterLockRecord): void {
753
+ this.record = record;
754
+ this.store.write(JSON.stringify(record));
755
+ }
756
+
471
757
  /** Drop the in-memory unlock without notifying โ€” for tests between cases. */
472
758
  resetForTest(): void {
473
759
  this.sessions.clear();
@@ -483,9 +769,9 @@ export class MasterLock {
483
769
  if (!record || this.sessions.size === 0) return;
484
770
  const now = this.now();
485
771
  const wasOpen = this.sessions.size > 0;
486
- for (const [token, lastActivityAt] of this.sessions) {
772
+ for (const [token, session] of this.sessions) {
487
773
  // Each device expires on its OWN clock, so one going idle never shortens another.
488
- if (now - lastActivityAt > record.idleMs) this.sessions.delete(token);
774
+ if (now - session.lastActivityAt > record.idleMs) this.sessions.delete(token);
489
775
  }
490
776
  if (wasOpen && this.sessions.size === 0) {
491
777
  this.clearExpiry();
@@ -507,7 +793,10 @@ export class MasterLock {
507
793
  if (!record || this.sessions.size === 0) return;
508
794
  // The SOONEST lapse, so `onLockedChange` cannot be late for the device that goes
509
795
  // first; the sweep it triggers re-arms for whichever is next.
510
- const soonest = Math.min(...this.sessions.values());
796
+ let soonest = Number.POSITIVE_INFINITY;
797
+ for (const session of this.sessions.values()) {
798
+ if (session.lastActivityAt < soonest) soonest = session.lastActivityAt;
799
+ }
511
800
  const due = soonest + record.idleMs - this.now();
512
801
  const timer = setTimeout(() => {
513
802
  this.expiryTimer = null;
@@ -545,9 +834,98 @@ export function readRecord(json: string | null | undefined): MasterLockRecord |
545
834
  if (parsed === null || typeof parsed !== "object") return null;
546
835
  const value = parsed as Record<string, unknown>;
547
836
  if (!isMasterLockKdfParams(value.kdf)) return null;
548
- const hash = typeof value.verifierHash === "string" ? unquote(value.verifierHash) : "";
837
+ const idleMs = clampIdleMs(value.idleMs);
838
+
839
+ /*
840
+ * ๐Ÿ”ด The LEGACY shape โ€” `{kdf, verifierHash, idleMs}`, one password and no accounts โ€”
841
+ * is still read, and reading it is not a courtesy. Three live things carry it:
842
+ *
843
+ * ยท every `MASTER_LOCK_SEED` in the fleet's 0600 secrets files, each one copied from
844
+ * `apps/vault`'s own `vault_keys` row, which will never learn a new shape;
845
+ * ยท `mintMasterLockSeed`, which produces exactly that record for a stage;
846
+ * ยท the row an app that has not yet been upgraded already has in its database.
847
+ *
848
+ * It becomes account {@link FIRST_ACCOUNT_ID}, whose id is FIXED โ€” the adopting app
849
+ * stamps its existing rows with that id, so a random one would orphan the entire
850
+ * library this migration exists to carry across. Nothing is rewritten on read; the
851
+ * upgraded shape is persisted the next time anything calls `write`.
852
+ */
853
+ if (!Array.isArray(value.accounts)) {
854
+ const hash = typeof value.verifierHash === "string" ? unquote(value.verifierHash) : "";
855
+ if (!hash.startsWith("$argon2")) return null;
856
+ return {
857
+ kdf: value.kdf,
858
+ idleMs,
859
+ accounts: [
860
+ {
861
+ id: FIRST_ACCOUNT_ID,
862
+ label: "Account 1",
863
+ hint: "",
864
+ verifierHash: hash,
865
+ createdAt: 0,
866
+ },
867
+ ],
868
+ };
869
+ }
870
+
871
+ const accounts: MasterLockAccount[] = [];
872
+ for (const entry of value.accounts) {
873
+ const account = readAccount(entry);
874
+ // ๐Ÿ”ด One malformed account drops THAT account, never the record. The alternative
875
+ // loses every other tenant to one bad row โ€” and this is the only copy of what says
876
+ // which password opens which library.
877
+ if (account && !accounts.some((seen) => seen.id === account.id)) accounts.push(account);
878
+ }
879
+ // An empty list is legitimate: an app nobody has chosen a password for yet.
880
+ return { kdf: value.kdf, idleMs, accounts };
881
+ }
882
+
883
+ function readAccount(value: unknown): MasterLockAccount | null {
884
+ if (value === null || typeof value !== "object") return null;
885
+ const row = value as Record<string, unknown>;
886
+ const id = typeof row.id === "string" ? row.id.trim() : "";
887
+ // The id reaches an app's SQL and its object-store key namespace, so the shape is
888
+ // fenced here rather than at every consumer โ€” the same reasoning
889
+ // `masterLockPrincipalKey` records for a principal id.
890
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id)) return null;
891
+ const hash = typeof row.verifierHash === "string" ? unquote(row.verifierHash) : "";
549
892
  if (!hash.startsWith("$argon2")) return null;
550
- return { kdf: value.kdf, verifierHash: hash, idleMs: clampIdleMs(value.idleMs) };
893
+ return {
894
+ id,
895
+ label: cleanLabel(typeof row.label === "string" ? row.label : "") || id,
896
+ hint: cleanHint(typeof row.hint === "string" ? row.hint : ""),
897
+ verifierHash: hash,
898
+ createdAt: typeof row.createdAt === "number" && Number.isFinite(row.createdAt) ? row.createdAt : 0,
899
+ };
900
+ }
901
+
902
+ /** `acct-1`, `acct-2`, โ€ฆ โ€” the lowest number no live account already holds. Sequential
903
+ * because the owner reads these ids out of his own vault and types them into a terminal;
904
+ * a random id would be one more thing to write down. */
905
+ function nextAccountId(accounts: readonly MasterLockAccount[]): string {
906
+ const taken = new Set(accounts.map((account) => account.id));
907
+ for (let n = 1; n <= accounts.length + 1; n += 1) {
908
+ const id = `acct-${n}`;
909
+ if (!taken.has(id)) return id;
910
+ }
911
+ return `acct-${accounts.length + 1}`;
912
+ }
913
+
914
+ /** A label is a display string. Trimmed, capped, and never empty-by-whitespace. */
915
+ function cleanLabel(value: string | undefined): string {
916
+ return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
917
+ }
918
+
919
+ /**
920
+ * A hint is the owner's own words, and the ONE field here that is readable while locked.
921
+ *
922
+ * Capped and stripped of newlines so it cannot become a payload or a wall of text on the
923
+ * lock screen. Deliberately NOT checked against the password: this server has never seen
924
+ * the password and cannot, which is the property the whole feature rests on โ€” so "your
925
+ * hint contains your password" is a warning the browser could give and this cannot.
926
+ */
927
+ function cleanHint(value: string | undefined): string {
928
+ return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
551
929
  }
552
930
 
553
931
  /**