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
|
@@ -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
|
-
/**
|
|
52
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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: "
|
|
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
|
|
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
|
-
/**
|
|
184
|
-
get
|
|
185
|
-
/**
|
|
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
|
|
250
|
+
* Is anybody's unlock live right now?
|
|
189
251
|
*
|
|
190
|
-
* 🔴 An UNCONFIGURED
|
|
191
|
-
*
|
|
192
|
-
* failing closed would
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
* 🔴
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
*
|
|
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
|
-
*
|
|
302
|
+
* The hints, readable WHILE LOCKED — the owner's *"let me setup hints for them"*.
|
|
215
303
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
*
|
|
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
|
|
225
|
-
* things that make it not are
|
|
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
|
|
228
|
-
* flag; rotation is {@link change}, which demands the current password
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
262
|
-
*
|
|
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
|