cursedbelt-server 2.1.0 → 3.0.1

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,10 @@
48
48
  * one who has been guessing.
49
49
  */
50
50
  import { clampIdleMs, isMasterLockKdfParams, windowRemaining, } from "cursedbelt-core/master-lock";
51
+ /** The id a legacy single-password record migrates to. Fixed, because the app that adopts
52
+ * this stamps its existing rows with it — a random id would orphan the library it is
53
+ * supposed to inherit. */
54
+ export const FIRST_ACCOUNT_ID = "acct-1";
51
55
  const TOKEN_BYTES = 24;
52
56
  /**
53
57
  * One app's lock. Constructed once at boot and held for the process's life.
@@ -64,8 +68,6 @@ export class MasterLock {
64
68
  now;
65
69
  onLockedChange;
66
70
  log;
67
- /** Per-person mode — see {@link MasterLockOptions.enrollable}. */
68
- enrollable;
69
71
  record;
70
72
  /**
71
73
  * The live unlocks: token → when that token last saw real interaction. Moved only by
@@ -93,7 +95,6 @@ export class MasterLock {
93
95
  this.now = options.now ?? Date.now;
94
96
  this.onLockedChange = options.onLockedChange;
95
97
  this.log = options.log ?? (() => undefined);
96
- this.enrollable = options.enrollable === true;
97
98
  this.record = readRecord(this.store.read());
98
99
  if (!this.record && options.seedJson) {
99
100
  const seeded = readRecord(options.seedJson);
@@ -109,9 +110,10 @@ export class MasterLock {
109
110
  }
110
111
  }
111
112
  }
112
- /** Is a master password set at all? An app with `false` here refuses nothing. */
113
+ /** Is any master password set at all? An app with `false` here admits nobody, and
114
+ * offers the choose-a-password form instead. */
113
115
  get configured() {
114
- return this.record !== null;
116
+ return this.accounts.length > 0;
115
117
  }
116
118
  /** The params the browser must derive under, or null. Public by design. */
117
119
  get kdf() {
@@ -120,50 +122,71 @@ export class MasterLock {
120
122
  get idleMs() {
121
123
  return this.record?.idleMs ?? clampIdleMs(undefined);
122
124
  }
123
- /** Is this lock in per-person mode? */
124
- get isEnrollable() {
125
- return this.enrollable;
125
+ /** Every account, oldest first. Empty on an app nobody has chosen a password for. */
126
+ get accounts() {
127
+ return this.record?.accounts ?? [];
126
128
  }
127
- /** May a first password be chosen right now? Per-person mode, and no record yet. */
129
+ /**
130
+ * May a first master password be chosen right now?
131
+ *
132
+ * 🔴 True whenever there is no account, on EVERY app — there is no longer a mode that
133
+ * decides this. The owner's ruling, on what an app with no master password should do:
134
+ * *"if there is no master password then it has to get one set before a user could add
135
+ * anything. It would be booting to nothing until the password is created."*
136
+ */
128
137
  get awaitingEnrollment() {
129
- return this.enrollable && this.record === null;
138
+ return this.accounts.length === 0;
130
139
  }
131
140
  /**
132
- * Is the site open right now?
141
+ * Is anybody's unlock live right now?
133
142
  *
134
- * 🔴 An UNCONFIGURED app-wide lock reads as UNLOCKED, and that is the deliberate
135
- * choice. A lock with no password cannot be opened by anybody, including the owner —
136
- * failing closed would brick an app rather than protect it, and this guards a UI whose
137
- * data is already behind the app's own owner-only auth. `/healthz` reports
138
- * `configured` so a smoke can catch the state externally instead of the owner
139
- * discovering it.
143
+ * 🔴 An UNCONFIGURED lock reads as LOCKED, and that INVERTS what this used to do.
144
+ * App-wide, "no password means open" was the right call while the lock only decided
145
+ * whether a UI was visible: failing closed would have bricked an app rather than
146
+ * protected it, since nobody could type a password that did not exist. Both halves of
147
+ * that reasoning are now gone. {@link enroll} means an app with no password offers to
148
+ * take one, so nothing bricks; and the password now decides WHICH TENANT'S DATA the
149
+ * app serves, so "open with no password" has no coherent answer to give — there is no
150
+ * account whose rows it could show. The owner ruled the same way: *"It would be
151
+ * booting to nothing until the password is created."*
140
152
  *
141
- * 🔴 In PER-PERSON mode the same reasoning runs the other way and this reads as
142
- * LOCKED. Nobody is bricked — {@link enroll} is open to a principal with no record —
143
- * and "has not chosen a password yet" describes every new account, so opening for it
144
- * would admit exactly the people the wall exists to stop. See
145
- * {@link MasterLockOptions.enrollable}.
153
+ * 🔴 And this is a HEALTH reading, not an authorization one. Nothing may admit a
154
+ * request because `unlocked` is true — see {@link accountFor}, which is what the guard
155
+ * asks. The two were the same question only while an app had one tenant.
146
156
  */
147
157
  get unlocked() {
148
- if (!this.record)
149
- return !this.enrollable;
158
+ if (!this.configured)
159
+ return false;
150
160
  this.sweep();
151
161
  return this.sessions.size > 0;
152
162
  }
153
- /** Milliseconds of idleness left before the unlock lapses; 0 when locked. */
154
163
  /** Milliseconds of idleness left before the LONGEST-lived unlock lapses; 0 when none
155
164
  * is live. The status page counts down with it, so it is the most generous of the
156
165
  * open devices rather than an arbitrary one. */
157
166
  get remainingMs() {
158
- if (!this.record)
167
+ const record = this.record;
168
+ if (!record)
159
169
  return 0;
160
170
  this.sweep();
161
171
  if (this.sessions.size === 0)
162
172
  return 0;
163
- const newest = Math.max(...this.sessions.values());
164
- return Math.max(0, newest + this.record.idleMs - this.now());
173
+ let newest = 0;
174
+ for (const session of this.sessions.values()) {
175
+ if (session.lastActivityAt > newest)
176
+ newest = session.lastActivityAt;
177
+ }
178
+ return Math.max(0, newest + record.idleMs - this.now());
165
179
  }
166
- status() {
180
+ /**
181
+ * The state, for the lock page and for the app's own header.
182
+ *
183
+ * `req` is optional only so a health check can ask without one. Pass it wherever there
184
+ * IS a request: without it the reply carries no account, and a client would read that
185
+ * as "locked" while holding a perfectly good unlock.
186
+ */
187
+ status(req) {
188
+ const accountId = req ? this.accountFor(req) : null;
189
+ const account = accountId ? this.accountById(accountId) : null;
167
190
  return {
168
191
  configured: this.configured,
169
192
  enrollable: this.awaitingEnrollment,
@@ -172,30 +195,87 @@ export class MasterLock {
172
195
  idleMs: this.idleMs,
173
196
  remainingMs: this.remainingMs,
174
197
  retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, this.now()),
198
+ accountId: account?.id ?? null,
199
+ accountLabel: account?.label ?? null,
175
200
  };
176
201
  }
177
- /** Does this request carry the live unlock? The cookie, or the header for a caller with
178
- * no cookie jar. */
179
- presents(req) {
202
+ /**
203
+ * 🔴 **WHICH account this request's unlock belongs to** — the one authorization
204
+ * question this class answers, and the one the guard asks.
205
+ *
206
+ * `null` means "no live unlock is presented here", which is the only thing that may
207
+ * ever refuse a request. It deliberately does not fall back to {@link unlocked}: that
208
+ * reads "somebody's device is open", and honouring it would make the unlock AMBIENT —
209
+ * a second browser signed in as the owner would walk through having typed nothing, and
210
+ * would then be served whichever tenant happened to be open. While an app had one
211
+ * password that was merely sloppy; with several it hands the wrong library to the
212
+ * wrong session.
213
+ *
214
+ * The token is matched in constant time against every live one, and deliberately
215
+ * WITHOUT a `Map.get` shortcut: a hash lookup on attacker-supplied bytes leaks by
216
+ * timing what the comparison is written to hide.
217
+ */
218
+ accountFor(req) {
180
219
  this.sweep();
181
220
  if (this.sessions.size === 0)
182
- return false;
183
- // Compared against every live token in constant time, and deliberately WITHOUT a
184
- // `Map.has` shortcut: a hash lookup on attacker-supplied bytes leaks by timing what
185
- // the comparison is written to hide.
221
+ return null;
222
+ let found = null;
186
223
  for (const presented of presentedTokens(req)) {
187
- for (const live of this.sessions.keys()) {
224
+ for (const [live, session] of this.sessions) {
225
+ // No early `return`: every candidate is compared against every live token so
226
+ // the loop's duration does not say which cookie in the jar was the right one.
188
227
  if (constantTimeEqual(presented, live))
189
- return true;
228
+ found ??= session.accountId;
190
229
  }
191
230
  }
192
- return false;
231
+ return found;
232
+ }
233
+ /** Does this request carry a live unlock? The cookie, or the header for a caller with
234
+ * no cookie jar. A thin reading of {@link accountFor}, which is the real answer. */
235
+ presents(req) {
236
+ return this.accountFor(req) !== null;
237
+ }
238
+ /** One account by id, or null. */
239
+ accountById(id) {
240
+ return this.accounts.find((account) => account.id === id) ?? null;
193
241
  }
194
242
  /**
195
- * Evaluate a verifier and, on a match, mint the unlock.
243
+ * The hints, readable WHILE LOCKED — the owner's *"let me setup hints for them"*.
196
244
  *
197
- * Runs an argon2 verify on EVERY path — no record, throttled, wrong — so the answer's
198
- * latency says nothing the answer itself does not.
245
+ * 🔴 This is the one place that discloses how many accounts exist, and it is a
246
+ * separate route for exactly that reason (see `MASTER_LOCK_PATHS.hints`). Everyone who
247
+ * can reach it has already passed the app's own owner-only sign-in, and the lock page
248
+ * asks for it only when somebody clicks "I forgot" — so the default screen stays
249
+ * byte-identical whether this app has one tenant or ten.
250
+ */
251
+ hints() {
252
+ return this.accounts.map((account) => ({ label: account.label, hint: account.hint }));
253
+ }
254
+ /** Every account, for the management UI. Never a hash, and never anything derived from
255
+ * a password. `current` marks the one the caller's own unlock opened. */
256
+ summaries(req) {
257
+ const current = this.accountFor(req);
258
+ return this.accounts.map((account) => ({
259
+ id: account.id,
260
+ label: account.label,
261
+ hint: account.hint,
262
+ createdAt: account.createdAt,
263
+ current: account.id === current,
264
+ }));
265
+ }
266
+ /**
267
+ * Evaluate a verifier and, on a match, mint the unlock for the account that matched.
268
+ *
269
+ * Runs an argon2 verify on EVERY path — no accounts, throttled, wrong — so the
270
+ * answer's latency says nothing the answer itself does not.
271
+ *
272
+ * 🔴 **Every account is tried, and the loop does not stop at the first match.**
273
+ * Stopping early is the obvious optimisation and it is a timing oracle: a password
274
+ * matching the first account would answer in one argon2id and a wrong one in N, so the
275
+ * shape of the reply would say how many tenants exist and roughly where in the list
276
+ * this one sits. The cost is bounded by how many passwords the owner has made — single
277
+ * digits — and every outcome now costs the same. (`apps/collections` reached the same
278
+ * conclusion for its per-file keys in 2026-08-14; this is that reasoning, kept.)
199
279
  */
200
280
  async unlock(verifier) {
201
281
  const now = this.now();
@@ -203,83 +283,190 @@ export class MasterLock {
203
283
  const record = this.record;
204
284
  // The equalizing verify happens first and unconditionally. `verifier` may be empty
205
285
  // or absurd; `verify` neither throws on that nor tells us anything, which is the point.
206
- const genuine = retryAfterMs === 0 && record ? await verifyArgon(verifier, record.verifierHash) : null;
286
+ let opened = null;
287
+ let unparsable = 0;
288
+ if (retryAfterMs === 0 && record) {
289
+ for (const account of record.accounts) {
290
+ const genuine = await verifyArgon(verifier, account.verifierHash);
291
+ // `null` is a hash this build cannot parse. It is NOT a wrong password, and
292
+ // saying so is what stops the owner re-typing a correct password forever —
293
+ // the exact day `apps/collections` lost to a quoted hash in its secrets file.
294
+ if (genuine === null)
295
+ unparsable += 1;
296
+ else if (genuine)
297
+ opened ??= account;
298
+ }
299
+ }
207
300
  await equalize(verifier);
208
301
  if (retryAfterMs > 0)
209
302
  return { ok: false, retryAfterMs };
210
- // `null` is a hash this build cannot parse. It is NOT a wrong password, and saying so
211
- // is what stops the owner re-typing a correct password forever — the exact day
212
- // `apps/collections` lost to a quoted hash in its secrets file.
213
- if (genuine === null) {
214
- if (record)
215
- this.log("🔴 master lock: the stored verifier hash cannot be parsed — nothing can unlock");
216
- return { ok: false, retryAfterMs: 0 };
217
- }
218
- if (!genuine) {
303
+ if (!opened) {
304
+ if (unparsable > 0 && unparsable === (record?.accounts.length ?? 0)) {
305
+ this.log("🔴 master lock: every stored verifier hash is unparsable — nothing can unlock");
306
+ return { ok: false, retryAfterMs: 0 };
307
+ }
219
308
  this.failures += 1;
220
309
  this.lastFailureAt = now;
221
310
  return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
222
311
  }
312
+ return { ok: true, ...this.open(opened.id, now) };
313
+ }
314
+ /**
315
+ * Mint a token for one account and start its idle clock. The one place a session is
316
+ * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
317
+ */
318
+ open(accountId, now) {
223
319
  this.failures = 0;
224
320
  this.lastFailureAt = 0;
225
321
  const token = randomToken();
226
322
  const wasLocked = this.sessions.size === 0;
227
323
  // ADDED, never replacing: the desk and the phone are the same person and both stay
228
324
  // open. See the `sessions` note.
229
- this.sessions.set(token, now);
325
+ this.sessions.set(token, { accountId, lastActivityAt: now });
230
326
  this.scheduleExpiry();
231
327
  if (wasLocked)
232
328
  this.onLockedChange?.(false);
233
- return { ok: true, token };
329
+ return { token, accountId };
234
330
  }
235
331
  /**
236
- * Choose the FIRST master password for this lock — per-person mode only.
332
+ * Choose the FIRST master password for this app, creating its first tenant.
237
333
  *
238
334
  * ── Why this is not a hole ─────────────────────────────────────────────────
239
- * It looks like "anyone who can reach the endpoint sets the password", and the three
240
- * things that make it not are all checked here rather than at the caller:
335
+ * It looks like "anyone who can reach the endpoint sets the password", and the two
336
+ * things that make it not are checked here rather than at the caller:
337
+ *
338
+ * 1. **It refuses once any account exists.** There is no overwrite branch and no
339
+ * force flag; rotation is {@link change}, which demands the current password
340
+ * re-typed, and a second tenant is {@link addAccount}, which demands the same. So
341
+ * this can only ever create the password that was never set.
342
+ * 2. **Whoever reaches it is already inside the building.** The guard runs behind the
343
+ * app's own sign-in, so to enroll at all a caller must hold a live session for an
344
+ * account the app admits — and if they hold that, the master lock was never what
345
+ * was standing between them and the app.
241
346
  *
242
- * 1. **It refuses once a record exists.** There is no overwrite branch and no force
243
- * flag; rotation is {@link change}, which demands the current password re-typed.
244
- * So this can only ever create the password that was never set.
245
- * 2. **It refuses outside per-person mode**, so mounting the route in an app-wide
246
- * app cannot silently arm a lock the owner did not choose.
247
- * 3. **The principal is resolved from the app's own session, not from the request
248
- * body.** That is the guard's job (`resolve`), and it is why a stranger cannot
249
- * enroll on somebody else's behalf: to reach this at all they must already hold a
250
- * live signed-in session for the account they are enrolling, and if they hold
251
- * that, the master lock was never what was standing between them and the app.
347
+ * 🔴 It is no longer gated on a per-person MODE. The owner's ruling is that an app
348
+ * with no master password boots to nothing until one is created, so the form is the
349
+ * way out of that state on every app — see {@link awaitingEnrollment}.
252
350
  *
253
- * The unlock is minted with the record, deliberately: being asked to type a password
351
+ * The unlock is minted with the account, deliberately: being asked to type a password
254
352
  * back at the person who just chose it, twice, teaches them the app is broken.
255
353
  */
256
354
  async enroll(input) {
257
- if (!this.enrollable)
258
- return { ok: false, reason: "not-enrollable" };
259
355
  // Re-read the store rather than trusting the field: two tabs can submit the
260
356
  // choose-a-password form at once, and the second must lose rather than replace.
261
- if (this.record || readRecord(this.store.read())) {
262
- this.record ??= readRecord(this.store.read());
357
+ const stored = readRecord(this.store.read());
358
+ if (stored && stored.accounts.length > 0) {
359
+ this.record = stored;
263
360
  return { ok: false, reason: "already-configured" };
264
361
  }
362
+ if (this.configured)
363
+ return { ok: false, reason: "already-configured" };
265
364
  if (!isMasterLockKdfParams(input.kdf) || typeof input.verifier !== "string" || !input.verifier) {
266
365
  return { ok: false, reason: "invalid" };
267
366
  }
268
- const next = {
269
- kdf: input.kdf,
367
+ const now = this.now();
368
+ const account = {
369
+ id: FIRST_ACCOUNT_ID,
370
+ label: cleanLabel(input.label) || "Account 1",
371
+ hint: cleanHint(input.hint),
270
372
  verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
271
- idleMs: clampIdleMs(undefined),
373
+ createdAt: now,
272
374
  };
273
- this.record = next;
274
- this.store.write(JSON.stringify(next));
275
- this.failures = 0;
276
- this.lastFailureAt = 0;
277
- const token = randomToken();
278
- this.sessions.set(token, this.now());
279
- this.scheduleExpiry();
280
- this.onLockedChange?.(false);
375
+ this.write({ kdf: input.kdf, idleMs: clampIdleMs(this.record?.idleMs), accounts: [account] });
281
376
  this.log("master lock: a first master password was chosen");
282
- return { ok: true, token };
377
+ return { ok: true, ...this.open(account.id, now) };
378
+ }
379
+ /**
380
+ * Add ANOTHER master password, and with it another tenant.
381
+ *
382
+ * The owner's model, in his words: *"The master passwords represent their own tenants
383
+ * or you can think of it as a fully separate account even though I am the user in both
384
+ * cases … If I login with a master password I only see the files I added or removed
385
+ * with that login."*
386
+ *
387
+ * ── The three refusals, and why each one is here ────────────────────────────
388
+ *
389
+ * 1. **No live unlock → refused.** Minting a tenant is an action from INSIDE the app,
390
+ * not a way into it.
391
+ * 2. **The current password, re-typed.** An open session proves somebody is at the
392
+ * keyboard, not that it is the owner — the same reasoning {@link change} runs on,
393
+ * and creating a hidden second library is at least as consequential as a rotation.
394
+ * 3. 🔴 **A password that already opens something is refused.** Two accounts sharing
395
+ * one password would make {@link unlock} serve whichever the loop reached first,
396
+ * so the owner would type the password they have always typed and be handed an
397
+ * empty library — with their real one apparently gone. It costs one argon2id per
398
+ * existing account, at creation time only.
399
+ *
400
+ * It does NOT unlock into the new account. Switching tenants means typing that
401
+ * tenant's password, which is the whole boundary.
402
+ */
403
+ async addAccount(req, input) {
404
+ const record = this.record;
405
+ if (!record || record.accounts.length === 0)
406
+ return { ok: false, reason: "unconfigured" };
407
+ const currentId = this.accountFor(req);
408
+ if (!currentId)
409
+ return { ok: false, reason: "locked" };
410
+ const current = this.accountById(currentId);
411
+ if (!current)
412
+ return { ok: false, reason: "locked" };
413
+ const label = cleanLabel(input.label);
414
+ if (!label || typeof input.verifier !== "string" || !input.verifier) {
415
+ return { ok: false, reason: "invalid" };
416
+ }
417
+ const genuine = await verifyArgon(input.currentVerifier, current.verifierHash);
418
+ await equalize(input.currentVerifier);
419
+ if (genuine === null)
420
+ return { ok: false, reason: "unconfigured" };
421
+ if (!genuine)
422
+ return { ok: false, reason: "wrong" };
423
+ for (const account of record.accounts) {
424
+ if (await verifyArgon(input.verifier, account.verifierHash)) {
425
+ return { ok: false, reason: "duplicate" };
426
+ }
427
+ }
428
+ const now = this.now();
429
+ const account = {
430
+ id: nextAccountId(record.accounts),
431
+ label,
432
+ hint: cleanHint(input.hint),
433
+ verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
434
+ createdAt: now,
435
+ };
436
+ this.write({ ...record, accounts: [...record.accounts, account] });
437
+ this.log(`master lock: a new account was created (${account.id})`);
438
+ return {
439
+ ok: true,
440
+ account: {
441
+ id: account.id,
442
+ label: account.label,
443
+ hint: account.hint,
444
+ createdAt: account.createdAt,
445
+ current: false,
446
+ },
447
+ };
448
+ }
449
+ /**
450
+ * Rename an account or rewrite its hint. Never its password — that is {@link change}.
451
+ *
452
+ * 🔴 Only the account the caller's own unlock opened. Editing a sibling would let a
453
+ * session that holds one tenant's password rewrite another tenant's hint, which is the
454
+ * one field designed to be read by somebody who is locked out.
455
+ */
456
+ editAccount(req, input) {
457
+ const record = this.record;
458
+ const currentId = this.accountFor(req);
459
+ if (!record || !currentId || currentId !== input.id)
460
+ return false;
461
+ const accounts = record.accounts.map((account) => account.id === input.id
462
+ ? {
463
+ ...account,
464
+ label: input.label === undefined ? account.label : cleanLabel(input.label) || account.label,
465
+ hint: input.hint === undefined ? account.hint : cleanHint(input.hint),
466
+ }
467
+ : account);
468
+ this.write({ ...record, accounts });
469
+ return true;
283
470
  }
284
471
  /**
285
472
  * Record real user interaction. The ONE thing that moves the idle clock — see decision 2
@@ -293,16 +480,20 @@ export class MasterLock {
293
480
  // Slides only the token that was presented. Two devices idle independently, so the
294
481
  // phone left on the counter locks on its own schedule while the desk stays open —
295
482
  // which is decision 2 applied per device rather than per person.
483
+ let slid = false;
296
484
  for (const presented of presentedTokens(req)) {
297
- for (const live of this.sessions.keys()) {
485
+ for (const [live, session] of this.sessions) {
486
+ // No early `return`, for the reason `accountFor` gives: the loop's duration
487
+ // must not say which of the jar's cookies was this app's.
298
488
  if (constantTimeEqual(presented, live)) {
299
- this.sessions.set(live, this.now());
300
- this.scheduleExpiry();
301
- return true;
489
+ this.sessions.set(live, { ...session, lastActivityAt: this.now() });
490
+ slid = true;
302
491
  }
303
492
  }
304
493
  }
305
- return false;
494
+ if (slid)
495
+ this.scheduleExpiry();
496
+ return slid;
306
497
  }
307
498
  /** The manual lock — the owner's *"I want a lock option to turn the site back to needing
308
499
  * the master password"*. Also what a change of password does to every open page. */
@@ -317,37 +508,61 @@ export class MasterLock {
317
508
  this.onLockedChange?.(true);
318
509
  }
319
510
  /**
320
- * Rotate this app's master password. Requires the CURRENT one, re-typed — an open session
321
- * proves somebody is at the keyboard, not that it is the owner, and the whole feature
322
- * exists for the minutes when it is not.
511
+ * Rotate ONE account's master password — the account the caller's own unlock opened.
323
512
  *
324
- * Per-app by construction: the record this writes is this app's own. The owner asked for
325
- * exactly that, and for there to be no way to change them all at once.
513
+ * Requires the current one, re-typed: an open session proves somebody is at the
514
+ * keyboard, not that it is the owner, and the whole feature exists for the minutes when
515
+ * it is not.
516
+ *
517
+ * 🔴 **The account id does not move, so the tenant's library does not move.** That is
518
+ * the owner's requirement stated as code: *"If I change account 2 password it just
519
+ * changes how I get the access. For instance I could change it from 'password' to
520
+ * 'passwordIPicked' later and I would still access the same content afterward."*
521
+ *
522
+ * 🔴 **No new KDF params.** They are shared by every account on this app, so minting a
523
+ * fresh salt here would invalidate every SIBLING's stored hash — rotating account 2
524
+ * would lock the owner out of account 1, silently, with no way back. See
525
+ * {@link MasterLockRecord}.
326
526
  */
327
- async change(input) {
527
+ async change(req, input) {
328
528
  const record = this.record;
329
- if (!record)
529
+ if (!record || record.accounts.length === 0)
330
530
  return { ok: false, reason: "unconfigured" };
331
- if (!isMasterLockKdfParams(input.kdf) || typeof input.verifier !== "string" || !input.verifier) {
531
+ const currentId = this.accountFor(req);
532
+ if (!currentId)
533
+ return { ok: false, reason: "locked" };
534
+ const current = this.accountById(currentId);
535
+ if (!current)
536
+ return { ok: false, reason: "locked" };
537
+ if (typeof input.verifier !== "string" || !input.verifier) {
332
538
  return { ok: false, reason: "invalid" };
333
539
  }
334
- const genuine = await verifyArgon(input.currentVerifier, record.verifierHash);
540
+ const genuine = await verifyArgon(input.currentVerifier, current.verifierHash);
335
541
  await equalize(input.currentVerifier);
336
542
  if (genuine === null)
337
543
  return { ok: false, reason: "unconfigured" };
338
544
  if (!genuine)
339
545
  return { ok: false, reason: "wrong" };
340
- const next = {
341
- kdf: input.kdf,
342
- verifierHash: await Bun.password.hash(input.verifier, { algorithm: "argon2id" }),
343
- idleMs: record.idleMs,
344
- };
345
- this.record = next;
346
- this.store.write(JSON.stringify(next));
546
+ // 🔴 Refused when the NEW password already opens a sibling, for the reason
547
+ // `addAccount` gives at length: two accounts under one password make `unlock` serve
548
+ // whichever the loop reaches first, and the owner would find the wrong library.
549
+ for (const account of record.accounts) {
550
+ if (account.id === current.id)
551
+ continue;
552
+ if (await verifyArgon(input.verifier, account.verifierHash)) {
553
+ return { ok: false, reason: "invalid" };
554
+ }
555
+ }
556
+ const verifierHash = await Bun.password.hash(input.verifier, { algorithm: "argon2id" });
557
+ this.write({
558
+ ...record,
559
+ accounts: record.accounts.map((account) => account.id === current.id ? { ...account, verifierHash } : account),
560
+ });
347
561
  // Every open page dies with the old password — otherwise rotating it after a bad day
348
- // leaves the session that worried you still holding the door.
562
+ // leaves the session that worried you still holding the door. ALL of them, including
563
+ // the other accounts': "lock this app" has never meant "lock part of it".
349
564
  this.lock();
350
- this.log("master lock: master password rotated for this app");
565
+ this.log(`master lock: password rotated for ${current.id}`);
351
566
  return { ok: true };
352
567
  }
353
568
  /** The owner's *"customizable by me"* idle timeout, clamped to the usable band. */
@@ -356,11 +571,16 @@ export class MasterLock {
356
571
  const idleMs = clampIdleMs(value);
357
572
  if (!record)
358
573
  return idleMs;
359
- this.record = { ...record, idleMs };
360
- this.store.write(JSON.stringify(this.record));
574
+ this.write({ ...record, idleMs });
361
575
  this.scheduleExpiry();
362
576
  return idleMs;
363
577
  }
578
+ /** Persist the record and keep the field in step. One place, so no path can write the
579
+ * store and forget the copy this process is answering from. */
580
+ write(record) {
581
+ this.record = record;
582
+ this.store.write(JSON.stringify(record));
583
+ }
364
584
  /** Drop the in-memory unlock without notifying — for tests between cases. */
365
585
  resetForTest() {
366
586
  this.sessions.clear();
@@ -376,9 +596,9 @@ export class MasterLock {
376
596
  return;
377
597
  const now = this.now();
378
598
  const wasOpen = this.sessions.size > 0;
379
- for (const [token, lastActivityAt] of this.sessions) {
599
+ for (const [token, session] of this.sessions) {
380
600
  // Each device expires on its OWN clock, so one going idle never shortens another.
381
- if (now - lastActivityAt > record.idleMs)
601
+ if (now - session.lastActivityAt > record.idleMs)
382
602
  this.sessions.delete(token);
383
603
  }
384
604
  if (wasOpen && this.sessions.size === 0) {
@@ -401,7 +621,11 @@ export class MasterLock {
401
621
  return;
402
622
  // The SOONEST lapse, so `onLockedChange` cannot be late for the device that goes
403
623
  // first; the sweep it triggers re-arms for whichever is next.
404
- const soonest = Math.min(...this.sessions.values());
624
+ let soonest = Number.POSITIVE_INFINITY;
625
+ for (const session of this.sessions.values()) {
626
+ if (session.lastActivityAt < soonest)
627
+ soonest = session.lastActivityAt;
628
+ }
405
629
  const due = soonest + record.idleMs - this.now();
406
630
  const timer = setTimeout(() => {
407
631
  this.expiryTimer = null;
@@ -441,10 +665,98 @@ export function readRecord(json) {
441
665
  const value = parsed;
442
666
  if (!isMasterLockKdfParams(value.kdf))
443
667
  return null;
444
- const hash = typeof value.verifierHash === "string" ? unquote(value.verifierHash) : "";
668
+ const idleMs = clampIdleMs(value.idleMs);
669
+ /*
670
+ * 🔴 The LEGACY shape — `{kdf, verifierHash, idleMs}`, one password and no accounts —
671
+ * is still read, and reading it is not a courtesy. Three live things carry it:
672
+ *
673
+ * · every `MASTER_LOCK_SEED` in the fleet's 0600 secrets files, each one copied from
674
+ * `apps/vault`'s own `vault_keys` row, which will never learn a new shape;
675
+ * · `mintMasterLockSeed`, which produces exactly that record for a stage;
676
+ * · the row an app that has not yet been upgraded already has in its database.
677
+ *
678
+ * It becomes account {@link FIRST_ACCOUNT_ID}, whose id is FIXED — the adopting app
679
+ * stamps its existing rows with that id, so a random one would orphan the entire
680
+ * library this migration exists to carry across. Nothing is rewritten on read; the
681
+ * upgraded shape is persisted the next time anything calls `write`.
682
+ */
683
+ if (!Array.isArray(value.accounts)) {
684
+ const hash = typeof value.verifierHash === "string" ? unquote(value.verifierHash) : "";
685
+ if (!hash.startsWith("$argon2"))
686
+ return null;
687
+ return {
688
+ kdf: value.kdf,
689
+ idleMs,
690
+ accounts: [
691
+ {
692
+ id: FIRST_ACCOUNT_ID,
693
+ label: "Account 1",
694
+ hint: "",
695
+ verifierHash: hash,
696
+ createdAt: 0,
697
+ },
698
+ ],
699
+ };
700
+ }
701
+ const accounts = [];
702
+ for (const entry of value.accounts) {
703
+ const account = readAccount(entry);
704
+ // 🔴 One malformed account drops THAT account, never the record. The alternative
705
+ // loses every other tenant to one bad row — and this is the only copy of what says
706
+ // which password opens which library.
707
+ if (account && !accounts.some((seen) => seen.id === account.id))
708
+ accounts.push(account);
709
+ }
710
+ // An empty list is legitimate: an app nobody has chosen a password for yet.
711
+ return { kdf: value.kdf, idleMs, accounts };
712
+ }
713
+ function readAccount(value) {
714
+ if (value === null || typeof value !== "object")
715
+ return null;
716
+ const row = value;
717
+ const id = typeof row.id === "string" ? row.id.trim() : "";
718
+ // The id reaches an app's SQL and its object-store key namespace, so the shape is
719
+ // fenced here rather than at every consumer — the same reasoning
720
+ // `masterLockPrincipalKey` records for a principal id.
721
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id))
722
+ return null;
723
+ const hash = typeof row.verifierHash === "string" ? unquote(row.verifierHash) : "";
445
724
  if (!hash.startsWith("$argon2"))
446
725
  return null;
447
- return { kdf: value.kdf, verifierHash: hash, idleMs: clampIdleMs(value.idleMs) };
726
+ return {
727
+ id,
728
+ label: cleanLabel(typeof row.label === "string" ? row.label : "") || id,
729
+ hint: cleanHint(typeof row.hint === "string" ? row.hint : ""),
730
+ verifierHash: hash,
731
+ createdAt: typeof row.createdAt === "number" && Number.isFinite(row.createdAt) ? row.createdAt : 0,
732
+ };
733
+ }
734
+ /** `acct-1`, `acct-2`, … — the lowest number no live account already holds. Sequential
735
+ * because the owner reads these ids out of his own vault and types them into a terminal;
736
+ * a random id would be one more thing to write down. */
737
+ function nextAccountId(accounts) {
738
+ const taken = new Set(accounts.map((account) => account.id));
739
+ for (let n = 1; n <= accounts.length + 1; n += 1) {
740
+ const id = `acct-${n}`;
741
+ if (!taken.has(id))
742
+ return id;
743
+ }
744
+ return `acct-${accounts.length + 1}`;
745
+ }
746
+ /** A label is a display string. Trimmed, capped, and never empty-by-whitespace. */
747
+ function cleanLabel(value) {
748
+ return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
749
+ }
750
+ /**
751
+ * A hint is the owner's own words, and the ONE field here that is readable while locked.
752
+ *
753
+ * Capped and stripped of newlines so it cannot become a payload or a wall of text on the
754
+ * lock screen. Deliberately NOT checked against the password: this server has never seen
755
+ * the password and cannot, which is the property the whole feature rests on — so "your
756
+ * hint contains your password" is a warning the browser could give and this cannot.
757
+ */
758
+ function cleanHint(value) {
759
+ return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
448
760
  }
449
761
  /**
450
762
  * Strip a wrapping quote pair.