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,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
|
|
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.
|
|
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
|
-
/**
|
|
124
|
-
get
|
|
125
|
-
return this.
|
|
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
|
-
/**
|
|
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.
|
|
138
|
+
return this.accounts.length === 0;
|
|
130
139
|
}
|
|
131
140
|
/**
|
|
132
|
-
* Is
|
|
141
|
+
* Is anybody's unlock live right now?
|
|
133
142
|
*
|
|
134
|
-
* 🔴 An UNCONFIGURED
|
|
135
|
-
*
|
|
136
|
-
* failing closed would
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
* 🔴
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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.
|
|
149
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
178
|
-
*
|
|
179
|
-
|
|
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
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
228
|
+
found ??= session.accountId;
|
|
190
229
|
}
|
|
191
230
|
}
|
|
192
|
-
return
|
|
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
|
-
*
|
|
243
|
+
* The hints, readable WHILE LOCKED — the owner's *"let me setup hints for them"*.
|
|
196
244
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 {
|
|
329
|
+
return { token, accountId };
|
|
234
330
|
}
|
|
235
331
|
/**
|
|
236
|
-
* Choose the FIRST master password for this
|
|
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
|
|
240
|
-
* things that make it not are
|
|
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
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
373
|
+
createdAt: now,
|
|
272
374
|
};
|
|
273
|
-
this.record
|
|
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,
|
|
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
|
|
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
|
-
|
|
301
|
-
return true;
|
|
489
|
+
this.sessions.set(live, { ...session, lastActivityAt: this.now() });
|
|
490
|
+
slid = true;
|
|
302
491
|
}
|
|
303
492
|
}
|
|
304
493
|
}
|
|
305
|
-
|
|
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
|
|
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
|
-
*
|
|
325
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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(
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|