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.
@@ -47,16 +47,75 @@
47
47
  * `retryAfterMs` a throttled caller is told — which they already know, since they are the
48
48
  * one who has been guessing.
49
49
  */
50
- import { type MasterLockKdfParams, type MasterLockStatus } from "cursedbelt-core/master-lock";
51
- /** The persisted record. Three public values — none of them is a secret, and the two that
52
- * look like one are a salt and a one-way hash. */
50
+ import { type MasterLockAccountSummary, type MasterLockKdfParams, type MasterLockStatus } from "cursedbelt-core/master-lock";
51
+ /**
52
+ * ONE master password, and therefore one TENANT of the app behind it.
53
+ *
54
+ * 🔴 The id is the account's identity and it never changes — not when the password is
55
+ * rotated, not when the label or the hint is rewritten. That is the property the whole
56
+ * model rests on, in the owner's words: *"If I change account 2 password it just changes
57
+ * how I get the access … I would still access the same content afterward."* An app stamps
58
+ * its rows with this id, so an id that moved would orphan a tenant's entire library.
59
+ */
60
+ export interface MasterLockAccount {
61
+ /** Stable, app-local, `[a-z0-9-]`. `acct-1` is the one a legacy record migrates to. */
62
+ id: string;
63
+ /** What the owner calls this tenant. Shown in the header once unlocked. */
64
+ label: string;
65
+ /**
66
+ * The owner's own reminder for this password, or `""`.
67
+ *
68
+ * Nobody — not this server, not an agent, not the owner's own backups — can recover a
69
+ * master password, so a forgotten one is a deleted tenant. The hint is the only
70
+ * recovery this design permits, and it is deliberately the owner's plain words rather
71
+ * than anything derived from the password.
72
+ */
73
+ hint: string;
74
+ /** `argon2id(verifier)`. The PHC string is self-describing, so there is no salt field. */
75
+ verifierHash: string;
76
+ createdAt: number;
77
+ }
78
+ /**
79
+ * The persisted record — the app's KEYRING.
80
+ *
81
+ * ── 🔴 ONE `kdf` for every account, and it is not an economy ────────────────
82
+ * Each account could carry its own salt. It must not, for three reasons, and the third is
83
+ * the security one:
84
+ *
85
+ * 1. **One derivation per attempt.** The browser runs 600,000 PBKDF2 iterations to turn a
86
+ * typed password into a verifier. Per-account salts would mean running that once per
87
+ * account before the server could say yes or no, so the lock page would get measurably
88
+ * slower every time the owner added a tenant.
89
+ * 2. **A rotation stays local.** `change` rewrites one account's `verifierHash` under
90
+ * these same params. A fresh salt would invalidate every sibling's stored hash at once.
91
+ * 3. **The page cannot be made to count the tenants.** `GET /__lock/status` has to publish
92
+ * the params before anyone has proved anything. With one descriptor it publishes one,
93
+ * whether there is a single account or ten — so the lock screen is byte-identical in
94
+ * both cases. With per-account salts it would have to publish a list, and the length of
95
+ * that list is exactly the fact this feature exists to keep quiet.
96
+ *
97
+ * What it costs: somebody holding this record can spend one PBKDF2 chain per candidate
98
+ * password and test it against every account, instead of one chain per account. Each
99
+ * candidate still costs a separate argon2id per account. Against a 16-byte random salt that
100
+ * is not a meaningful saving, and it is the same trade the fleet already makes by seeding an
101
+ * app from the vault's own params.
102
+ */
53
103
  export interface MasterLockRecord {
54
104
  kdf: MasterLockKdfParams;
55
- /** `argon2id(verifier)`. The PHC string is self-describing, so there is no salt column. */
56
- verifierHash: string;
57
- /** The owner's configured idle timeout for THIS app. */
105
+ /** The owner's configured idle timeout for THIS app. One clock, all accounts. */
58
106
  idleMs: number;
107
+ /**
108
+ * Every master password this app answers to, oldest first.
109
+ *
110
+ * 🔴 EMPTY is a real and expected state — a fresh app nobody has chosen a password for
111
+ * yet. It reads as LOCKED and offers enrollment; see {@link MasterLock.unlocked}.
112
+ */
113
+ accounts: MasterLockAccount[];
59
114
  }
115
+ /** The id a legacy single-password record migrates to. Fixed, because the app that adopts
116
+ * this stamps its existing rows with it — a random id would orphan the library it is
117
+ * supposed to inherit. */
118
+ export declare const FIRST_ACCOUNT_ID = "acct-1";
60
119
  /**
61
120
  * Where the record rests. One blob, so an app can keep it in whatever it already has —
62
121
  * binary-server's `settings` table, a satellite's `app_settings` row, a JSON file.
@@ -96,32 +155,19 @@ export interface MasterLockOptions {
96
155
  onLockedChange?: (locked: boolean) => void;
97
156
  /** Where to log the few things worth a line (a seed adopted, a rotation). */
98
157
  log?: (line: string) => void;
99
- /**
100
- * PER-PERSON mode. Inverts decision 5 below: with no record this lock reads as
101
- * LOCKED rather than open, and {@link MasterLock.enroll} is the way out.
102
- *
103
- * 🔴 It is an option and not the default because the two modes fail in opposite
104
- * directions, and each is right for exactly one situation:
105
- *
106
- * - **App-wide (`false`, the default).** One record for the whole app, seeded
107
- * from `MASTER_LOCK_SEED`. Unconfigured must read as OPEN, or a missing seed
108
- * bricks an app nobody can get into — see `unlocked`.
109
- * - **Per-person (`true`).** One record per signed-in account, and there is no
110
- * seed: each person chooses their own. Unconfigured must read as LOCKED,
111
- * because "this person has not chosen a password yet" is the state of every
112
- * NEW account, and treating it as open would mean the wall admits everybody
113
- * it has never met — the precise opposite of the feature.
114
- *
115
- * Nothing bricks in per-person mode, because enrollment is always available to
116
- * a principal with no record. That is what makes failing closed affordable here
117
- * and unaffordable there.
118
- */
119
- enrollable?: boolean;
120
158
  }
121
- /** What an unlock attempt answers. `ok` carries the token to set as a cookie. */
159
+ /**
160
+ * What an unlock attempt answers. `ok` carries the token to set as a cookie, and the
161
+ * ACCOUNT it opened.
162
+ *
163
+ * 🔴 The account id is the answer to "whose data do I serve now", and it comes from the
164
+ * password that was typed — never from anything the caller sends. A refusal carries no
165
+ * account, and says nothing about which of them was closest.
166
+ */
122
167
  export type MasterLockUnlockResult = {
123
168
  ok: true;
124
169
  token: string;
170
+ accountId: string;
125
171
  } | {
126
172
  ok: false;
127
173
  retryAfterMs: number;
@@ -131,9 +177,19 @@ export type MasterLockUnlockResult = {
131
177
  export type MasterLockEnrollResult = {
132
178
  ok: true;
133
179
  token: string;
180
+ accountId: string;
134
181
  } | {
135
182
  ok: false;
136
- reason: "not-enrollable" | "already-configured" | "invalid";
183
+ reason: "already-configured" | "invalid";
184
+ };
185
+ /** What adding a SECOND (or fifth) master password answers. It does NOT unlock into the
186
+ * new account — the owner stays where they were until they type the new password. */
187
+ export type MasterLockAddAccountResult = {
188
+ ok: true;
189
+ account: MasterLockAccountSummary;
190
+ } | {
191
+ ok: false;
192
+ reason: "wrong" | "invalid" | "duplicate" | "unconfigured" | "locked";
137
193
  };
138
194
  /**
139
195
  * One app's lock. Constructed once at boot and held for the process's life.
@@ -150,8 +206,6 @@ export declare class MasterLock {
150
206
  private readonly now;
151
207
  private readonly onLockedChange;
152
208
  private readonly log;
153
- /** Per-person mode — see {@link MasterLockOptions.enrollable}. */
154
- private readonly enrollable;
155
209
  private record;
156
210
  /**
157
211
  * The live unlocks: token → when that token last saw real interaction. Moved only by
@@ -175,73 +229,182 @@ export declare class MasterLock {
175
229
  private failures;
176
230
  private lastFailureAt;
177
231
  constructor(options: MasterLockOptions);
178
- /** Is a master password set at all? An app with `false` here refuses nothing. */
232
+ /** Is any master password set at all? An app with `false` here admits nobody, and
233
+ * offers the choose-a-password form instead. */
179
234
  get configured(): boolean;
180
235
  /** The params the browser must derive under, or null. Public by design. */
181
236
  get kdf(): MasterLockKdfParams | null;
182
237
  get idleMs(): number;
183
- /** Is this lock in per-person mode? */
184
- get isEnrollable(): boolean;
185
- /** May a first password be chosen right now? Per-person mode, and no record yet. */
238
+ /** Every account, oldest first. Empty on an app nobody has chosen a password for. */
239
+ get accounts(): readonly MasterLockAccount[];
240
+ /**
241
+ * May a first master password be chosen right now?
242
+ *
243
+ * 🔴 True whenever there is no account, on EVERY app — there is no longer a mode that
244
+ * decides this. The owner's ruling, on what an app with no master password should do:
245
+ * *"if there is no master password then it has to get one set before a user could add
246
+ * anything. It would be booting to nothing until the password is created."*
247
+ */
186
248
  get awaitingEnrollment(): boolean;
187
249
  /**
188
- * Is the site open right now?
250
+ * Is anybody's unlock live right now?
189
251
  *
190
- * 🔴 An UNCONFIGURED app-wide lock reads as UNLOCKED, and that is the deliberate
191
- * choice. A lock with no password cannot be opened by anybody, including the owner —
192
- * failing closed would brick an app rather than protect it, and this guards a UI whose
193
- * data is already behind the app's own owner-only auth. `/healthz` reports
194
- * `configured` so a smoke can catch the state externally instead of the owner
195
- * discovering it.
252
+ * 🔴 An UNCONFIGURED lock reads as LOCKED, and that INVERTS what this used to do.
253
+ * App-wide, "no password means open" was the right call while the lock only decided
254
+ * whether a UI was visible: failing closed would have bricked an app rather than
255
+ * protected it, since nobody could type a password that did not exist. Both halves of
256
+ * that reasoning are now gone. {@link enroll} means an app with no password offers to
257
+ * take one, so nothing bricks; and the password now decides WHICH TENANT'S DATA the
258
+ * app serves, so "open with no password" has no coherent answer to give — there is no
259
+ * account whose rows it could show. The owner ruled the same way: *"It would be
260
+ * booting to nothing until the password is created."*
196
261
  *
197
- * 🔴 In PER-PERSON mode the same reasoning runs the other way and this reads as
198
- * LOCKED. Nobody is bricked — {@link enroll} is open to a principal with no record —
199
- * and "has not chosen a password yet" describes every new account, so opening for it
200
- * would admit exactly the people the wall exists to stop. See
201
- * {@link MasterLockOptions.enrollable}.
262
+ * 🔴 And this is a HEALTH reading, not an authorization one. Nothing may admit a
263
+ * request because `unlocked` is true — see {@link accountFor}, which is what the guard
264
+ * asks. The two were the same question only while an app had one tenant.
202
265
  */
203
266
  get unlocked(): boolean;
204
- /** Milliseconds of idleness left before the unlock lapses; 0 when locked. */
205
267
  /** Milliseconds of idleness left before the LONGEST-lived unlock lapses; 0 when none
206
268
  * is live. The status page counts down with it, so it is the most generous of the
207
269
  * open devices rather than an arbitrary one. */
208
270
  get remainingMs(): number;
209
- status(): MasterLockStatus;
210
- /** Does this request carry the live unlock? The cookie, or the header for a caller with
211
- * no cookie jar. */
271
+ /**
272
+ * The state, for the lock page and for the app's own header.
273
+ *
274
+ * `req` is optional only so a health check can ask without one. Pass it wherever there
275
+ * IS a request: without it the reply carries no account, and a client would read that
276
+ * as "locked" while holding a perfectly good unlock.
277
+ */
278
+ status(req?: Request): MasterLockStatus;
279
+ /**
280
+ * 🔴 **WHICH account this request's unlock belongs to** — the one authorization
281
+ * question this class answers, and the one the guard asks.
282
+ *
283
+ * `null` means "no live unlock is presented here", which is the only thing that may
284
+ * ever refuse a request. It deliberately does not fall back to {@link unlocked}: that
285
+ * reads "somebody's device is open", and honouring it would make the unlock AMBIENT —
286
+ * a second browser signed in as the owner would walk through having typed nothing, and
287
+ * would then be served whichever tenant happened to be open. While an app had one
288
+ * password that was merely sloppy; with several it hands the wrong library to the
289
+ * wrong session.
290
+ *
291
+ * The token is matched in constant time against every live one, and deliberately
292
+ * WITHOUT a `Map.get` shortcut: a hash lookup on attacker-supplied bytes leaks by
293
+ * timing what the comparison is written to hide.
294
+ */
295
+ accountFor(req: Request): string | null;
296
+ /** Does this request carry a live unlock? The cookie, or the header for a caller with
297
+ * no cookie jar. A thin reading of {@link accountFor}, which is the real answer. */
212
298
  presents(req: Request): boolean;
299
+ /** One account by id, or null. */
300
+ accountById(id: string): MasterLockAccount | null;
213
301
  /**
214
- * Evaluate a verifier and, on a match, mint the unlock.
302
+ * The hints, readable WHILE LOCKED — the owner's *"let me setup hints for them"*.
215
303
  *
216
- * Runs an argon2 verify on EVERY path — no record, throttled, wrong — so the answer's
217
- * latency says nothing the answer itself does not.
304
+ * 🔴 This is the one place that discloses how many accounts exist, and it is a
305
+ * separate route for exactly that reason (see `MASTER_LOCK_PATHS.hints`). Everyone who
306
+ * can reach it has already passed the app's own owner-only sign-in, and the lock page
307
+ * asks for it only when somebody clicks "I forgot" — so the default screen stays
308
+ * byte-identical whether this app has one tenant or ten.
309
+ */
310
+ hints(): {
311
+ label: string;
312
+ hint: string;
313
+ }[];
314
+ /** Every account, for the management UI. Never a hash, and never anything derived from
315
+ * a password. `current` marks the one the caller's own unlock opened. */
316
+ summaries(req: Request): MasterLockAccountSummary[];
317
+ /**
318
+ * Evaluate a verifier and, on a match, mint the unlock for the account that matched.
319
+ *
320
+ * Runs an argon2 verify on EVERY path — no accounts, throttled, wrong — so the
321
+ * answer's latency says nothing the answer itself does not.
322
+ *
323
+ * 🔴 **Every account is tried, and the loop does not stop at the first match.**
324
+ * Stopping early is the obvious optimisation and it is a timing oracle: a password
325
+ * matching the first account would answer in one argon2id and a wrong one in N, so the
326
+ * shape of the reply would say how many tenants exist and roughly where in the list
327
+ * this one sits. The cost is bounded by how many passwords the owner has made — single
328
+ * digits — and every outcome now costs the same. (`apps/collections` reached the same
329
+ * conclusion for its per-file keys in 2026-08-14; this is that reasoning, kept.)
218
330
  */
219
331
  unlock(verifier: string): Promise<MasterLockUnlockResult>;
220
332
  /**
221
- * Choose the FIRST master password for this lock — per-person mode only.
333
+ * Mint a token for one account and start its idle clock. The one place a session is
334
+ * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
335
+ */
336
+ private open;
337
+ /**
338
+ * Choose the FIRST master password for this app, creating its first tenant.
222
339
  *
223
340
  * ── Why this is not a hole ─────────────────────────────────────────────────
224
- * It looks like "anyone who can reach the endpoint sets the password", and the three
225
- * things that make it not are all checked here rather than at the caller:
341
+ * It looks like "anyone who can reach the endpoint sets the password", and the two
342
+ * things that make it not are checked here rather than at the caller:
226
343
  *
227
- * 1. **It refuses once a record exists.** There is no overwrite branch and no force
228
- * flag; rotation is {@link change}, which demands the current password re-typed.
229
- * So this can only ever create the password that was never set.
230
- * 2. **It refuses outside per-person mode**, so mounting the route in an app-wide
231
- * app cannot silently arm a lock the owner did not choose.
232
- * 3. **The principal is resolved from the app's own session, not from the request
233
- * body.** That is the guard's job (`resolve`), and it is why a stranger cannot
234
- * enroll on somebody else's behalf: to reach this at all they must already hold a
235
- * live signed-in session for the account they are enrolling, and if they hold
236
- * that, the master lock was never what was standing between them and the app.
344
+ * 1. **It refuses once any account exists.** There is no overwrite branch and no
345
+ * force flag; rotation is {@link change}, which demands the current password
346
+ * re-typed, and a second tenant is {@link addAccount}, which demands the same. So
347
+ * this can only ever create the password that was never set.
348
+ * 2. **Whoever reaches it is already inside the building.** The guard runs behind the
349
+ * app's own sign-in, so to enroll at all a caller must hold a live session for an
350
+ * account the app admits — and if they hold that, the master lock was never what
351
+ * was standing between them and the app.
237
352
  *
238
- * The unlock is minted with the record, deliberately: being asked to type a password
353
+ * 🔴 It is no longer gated on a per-person MODE. The owner's ruling is that an app
354
+ * with no master password boots to nothing until one is created, so the form is the
355
+ * way out of that state on every app — see {@link awaitingEnrollment}.
356
+ *
357
+ * The unlock is minted with the account, deliberately: being asked to type a password
239
358
  * back at the person who just chose it, twice, teaches them the app is broken.
240
359
  */
241
360
  enroll(input: {
242
361
  kdf: MasterLockKdfParams;
243
362
  verifier: string;
363
+ label?: string;
364
+ hint?: string;
244
365
  }): Promise<MasterLockEnrollResult>;
366
+ /**
367
+ * Add ANOTHER master password, and with it another tenant.
368
+ *
369
+ * The owner's model, in his words: *"The master passwords represent their own tenants
370
+ * or you can think of it as a fully separate account even though I am the user in both
371
+ * cases … If I login with a master password I only see the files I added or removed
372
+ * with that login."*
373
+ *
374
+ * ── The three refusals, and why each one is here ────────────────────────────
375
+ *
376
+ * 1. **No live unlock → refused.** Minting a tenant is an action from INSIDE the app,
377
+ * not a way into it.
378
+ * 2. **The current password, re-typed.** An open session proves somebody is at the
379
+ * keyboard, not that it is the owner — the same reasoning {@link change} runs on,
380
+ * and creating a hidden second library is at least as consequential as a rotation.
381
+ * 3. 🔴 **A password that already opens something is refused.** Two accounts sharing
382
+ * one password would make {@link unlock} serve whichever the loop reached first,
383
+ * so the owner would type the password they have always typed and be handed an
384
+ * empty library — with their real one apparently gone. It costs one argon2id per
385
+ * existing account, at creation time only.
386
+ *
387
+ * It does NOT unlock into the new account. Switching tenants means typing that
388
+ * tenant's password, which is the whole boundary.
389
+ */
390
+ addAccount(req: Request, input: {
391
+ currentVerifier: string;
392
+ verifier: string;
393
+ label: string;
394
+ hint?: string;
395
+ }): Promise<MasterLockAddAccountResult>;
396
+ /**
397
+ * Rename an account or rewrite its hint. Never its password — that is {@link change}.
398
+ *
399
+ * 🔴 Only the account the caller's own unlock opened. Editing a sibling would let a
400
+ * session that holds one tenant's password rewrite another tenant's hint, which is the
401
+ * one field designed to be read by somebody who is locked out.
402
+ */
403
+ editAccount(req: Request, input: {
404
+ id: string;
405
+ label?: string;
406
+ hint?: string;
407
+ }): boolean;
245
408
  /**
246
409
  * Record real user interaction. The ONE thing that moves the idle clock — see decision 2
247
410
  * in the module note.
@@ -254,25 +417,36 @@ export declare class MasterLock {
254
417
  * the master password"*. Also what a change of password does to every open page. */
255
418
  lock(): void;
256
419
  /**
257
- * Rotate this app's master password. Requires the CURRENT one, re-typed — an open session
258
- * proves somebody is at the keyboard, not that it is the owner, and the whole feature
259
- * exists for the minutes when it is not.
420
+ * Rotate ONE account's master password — the account the caller's own unlock opened.
260
421
  *
261
- * Per-app by construction: the record this writes is this app's own. The owner asked for
262
- * exactly that, and for there to be no way to change them all at once.
422
+ * Requires the current one, re-typed: an open session proves somebody is at the
423
+ * keyboard, not that it is the owner, and the whole feature exists for the minutes when
424
+ * it is not.
425
+ *
426
+ * 🔴 **The account id does not move, so the tenant's library does not move.** That is
427
+ * the owner's requirement stated as code: *"If I change account 2 password it just
428
+ * changes how I get the access. For instance I could change it from 'password' to
429
+ * 'passwordIPicked' later and I would still access the same content afterward."*
430
+ *
431
+ * 🔴 **No new KDF params.** They are shared by every account on this app, so minting a
432
+ * fresh salt here would invalidate every SIBLING's stored hash — rotating account 2
433
+ * would lock the owner out of account 1, silently, with no way back. See
434
+ * {@link MasterLockRecord}.
263
435
  */
264
- change(input: {
436
+ change(req: Request, input: {
265
437
  currentVerifier: string;
266
- kdf: MasterLockKdfParams;
267
438
  verifier: string;
268
439
  }): Promise<{
269
440
  ok: true;
270
441
  } | {
271
442
  ok: false;
272
- reason: "wrong" | "invalid" | "unconfigured";
443
+ reason: "wrong" | "invalid" | "unconfigured" | "locked";
273
444
  }>;
274
445
  /** The owner's *"customizable by me"* idle timeout, clamped to the usable band. */
275
446
  setIdleMs(value: number): number;
447
+ /** Persist the record and keep the field in step. One place, so no path can write the
448
+ * store and forget the copy this process is answering from. */
449
+ private write;
276
450
  /** Drop the in-memory unlock without notifying — for tests between cases. */
277
451
  resetForTest(): void;
278
452
  /** Retire a session whose idle window has passed. Called on every read of the state, so