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.
- package/dist/server/activity/index.d.ts +1 -1
- package/dist/server/activity/index.js +1 -1
- package/dist/server/master-lock/guard.d.ts +10 -0
- package/dist/server/master-lock/guard.js +88 -19
- package/dist/server/master-lock/index.d.ts +1 -1
- package/dist/server/master-lock/index.js +1 -1
- package/dist/server/master-lock/lockPage.d.ts +1 -1
- package/dist/server/master-lock/lockPage.js +68 -3
- package/dist/server/master-lock/masterLock.d.ts +250 -76
- package/dist/server/master-lock/masterLock.js +426 -114
- package/dist/server/master-lock/principals.js +6 -1
- package/dist/server/master-lock/seed.d.ts +5 -1
- package/dist/server/master-lock/seed.js +18 -1
- package/package.json +2 -2
- package/src/server/activity/index.ts +1 -1
- package/src/server/master-lock/accounts.spec.ts +308 -0
- package/src/server/master-lock/guard.spec.ts +94 -7
- package/src/server/master-lock/guard.ts +99 -20
- package/src/server/master-lock/index.ts +3 -0
- package/src/server/master-lock/lockPage.ts +70 -3
- package/src/server/master-lock/masterLock.spec.ts +56 -23
- package/src/server/master-lock/masterLock.ts +529 -151
- package/src/server/master-lock/principals.spec.ts +45 -15
- package/src/server/master-lock/principals.ts +6 -1
- package/src/server/master-lock/seed.spec.ts +7 -2
- package/src/server/master-lock/seed.ts +22 -2
|
@@ -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
|
-
/**
|
|
59
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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: "
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
222
|
-
get
|
|
223
|
-
return this.
|
|
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
|
-
/**
|
|
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.
|
|
293
|
+
return this.accounts.length === 0;
|
|
229
294
|
}
|
|
230
295
|
|
|
231
296
|
/**
|
|
232
|
-
* Is
|
|
233
|
-
*
|
|
234
|
-
* ๐ด An UNCONFIGURED
|
|
235
|
-
*
|
|
236
|
-
* failing closed would
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* {@link
|
|
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.
|
|
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
|
-
|
|
323
|
+
const record = this.record;
|
|
324
|
+
if (!record) return 0;
|
|
259
325
|
this.sweep();
|
|
260
326
|
if (this.sessions.size === 0) return 0;
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
278
|
-
*
|
|
279
|
-
|
|
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
|
|
282
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
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
|
-
*
|
|
399
|
+
* The hints, readable WHILE LOCKED โ the owner's *"let me setup hints for them"*.
|
|
295
400
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 {
|
|
488
|
+
return { token, accountId };
|
|
333
489
|
}
|
|
334
490
|
|
|
335
491
|
/**
|
|
336
|
-
* Choose the FIRST master password for this
|
|
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
|
|
340
|
-
* things that make it not are
|
|
341
|
-
*
|
|
342
|
-
* 1. **It refuses once
|
|
343
|
-
* flag; rotation is {@link change}, which demands the current password
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
537
|
+
createdAt: now,
|
|
374
538
|
};
|
|
375
|
-
this.record
|
|
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,
|
|
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
|
|
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
|
-
|
|
404
|
-
return true;
|
|
659
|
+
this.sessions.set(live, { ...session, lastActivityAt: this.now() });
|
|
660
|
+
slid = true;
|
|
405
661
|
}
|
|
406
662
|
}
|
|
407
663
|
}
|
|
408
|
-
|
|
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
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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
|
-
*
|
|
429
|
-
*
|
|
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(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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(
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
/**
|