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
|
@@ -81,6 +81,16 @@ const LOCK_CSP =
|
|
|
81
81
|
export interface MasterLockGuard {
|
|
82
82
|
/** `Response` when handled, `null` when the app should serve the request. */
|
|
83
83
|
handle(req: Request): Promise<Response | null>;
|
|
84
|
+
/**
|
|
85
|
+
* 🔴 **WHICH tenant this request is for** — the answer an app must scope every query by.
|
|
86
|
+
*
|
|
87
|
+
* `null` when the request carries no live unlock, which is exactly when {@link handle}
|
|
88
|
+
* would have refused it. So the safe shape in an app is to read this AFTER the guard has
|
|
89
|
+
* stood aside, and to treat `null` as "serve nothing" rather than "serve the default" —
|
|
90
|
+
* a fallback tenant is how a locked request ends up being answered with somebody's
|
|
91
|
+
* library.
|
|
92
|
+
*/
|
|
93
|
+
accountFor(req: Request): string | null;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLockGuard {
|
|
@@ -105,24 +115,23 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
105
115
|
);
|
|
106
116
|
|
|
107
117
|
/**
|
|
108
|
-
* 🔴 May THIS request through — and the
|
|
109
|
-
* security property of the per-person wall.
|
|
118
|
+
* 🔴 May THIS request through — and the answer is a PRESENTED TOKEN, always.
|
|
110
119
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
120
|
+
* Until the accounts model there was a second branch: an app-wide lock let `unlocked`
|
|
121
|
+
* alone open the door, on the reasoning that such a site had one user and one unlock,
|
|
122
|
+
* so the token was merely how a caller with no cookie jar said "it's still me". The
|
|
123
|
+
* per-person wall already refused that shape, and 2026-08-25 measured why — with
|
|
124
|
+
* `unlocked ||` in front, one browser unlocking meant every OTHER browser signed in as
|
|
125
|
+
* that person walked straight through having typed nothing, and an invented
|
|
126
|
+
* `x-master-lock` value was accepted because the check never looked at it.
|
|
115
127
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* `x-master-lock` value would be accepted because the check never looked at it. Two
|
|
122
|
-
* tests in `principals.spec.ts` fail on the `||`, which is why it is gone.
|
|
128
|
+
* The accounts model removes the last reason to keep the branch, and turns it from
|
|
129
|
+
* sloppy into wrong: the password decides WHICH TENANT'S DATA the app serves, so a
|
|
130
|
+
* request with no token has no account, and admitting it means serving whichever
|
|
131
|
+
* library somebody else's browser happens to have open. There is one rule now, for
|
|
132
|
+
* every app.
|
|
123
133
|
*/
|
|
124
|
-
const opens = (lock: MasterLock, req: Request): boolean =>
|
|
125
|
-
lock.isEnrollable ? lock.presents(req) : lock.unlocked || lock.presents(req);
|
|
134
|
+
const opens = (lock: MasterLock, req: Request): boolean => lock.presents(req);
|
|
126
135
|
|
|
127
136
|
async function handleOwnRoute(req: Request, url: URL, lock: MasterLock): Promise<Response> {
|
|
128
137
|
const path = url.pathname;
|
|
@@ -140,7 +149,21 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
140
149
|
});
|
|
141
150
|
}
|
|
142
151
|
if (path === MASTER_LOCK_PATHS.status) {
|
|
143
|
-
return Response.json(lock.status(), { headers: noStore });
|
|
152
|
+
return Response.json(lock.status(req), { headers: noStore });
|
|
153
|
+
}
|
|
154
|
+
if (path === MASTER_LOCK_PATHS.hints) {
|
|
155
|
+
// 🔴 Readable WHILE LOCKED, and the only surface here that is. See the path's
|
|
156
|
+
// note in `cursedbelt-core/master-lock/wire.ts`: a hint nobody can read until
|
|
157
|
+
// they are already in is not a hint, and everyone who reaches this has passed
|
|
158
|
+
// the app's own owner-only sign-in. It is a separate route, fetched only when
|
|
159
|
+
// somebody clicks "I forgot", so the default lock screen stays byte-identical
|
|
160
|
+
// whether this app has one tenant or ten.
|
|
161
|
+
return Response.json({ hints: lock.hints() }, { headers: noStore });
|
|
162
|
+
}
|
|
163
|
+
if (path === MASTER_LOCK_PATHS.accounts) {
|
|
164
|
+
// Unlike the hints, this needs the site already open — it names every tenant.
|
|
165
|
+
if (!opens(lock, req)) return refuse();
|
|
166
|
+
return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
|
|
144
167
|
}
|
|
145
168
|
if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
|
|
146
169
|
// Already open FOR THIS CALLER, so there is nothing to type — send them to
|
|
@@ -153,6 +176,18 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
153
176
|
return new Response("not found", { status: 404, headers: noStore });
|
|
154
177
|
}
|
|
155
178
|
|
|
179
|
+
if (req.method === "PATCH" && path === MASTER_LOCK_PATHS.accounts) {
|
|
180
|
+
if (!opens(lock, req)) return refuse();
|
|
181
|
+
const body = await readJson(req);
|
|
182
|
+
const ok = lock.editAccount(req, {
|
|
183
|
+
id: typeof body?.id === "string" ? body.id : "",
|
|
184
|
+
...(typeof body?.label === "string" ? { label: body.label } : {}),
|
|
185
|
+
...(typeof body?.hint === "string" ? { hint: body.hint } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (!ok) return Response.json({ error: "wrong" }, { status: 400, headers: noStore });
|
|
188
|
+
return Response.json({ ok: true, accounts: lock.summaries(req) }, { headers: noStore });
|
|
189
|
+
}
|
|
190
|
+
|
|
156
191
|
if (req.method !== "POST") {
|
|
157
192
|
return new Response("method not allowed", { status: 405, headers: noStore });
|
|
158
193
|
}
|
|
@@ -168,7 +203,7 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
168
203
|
// becomes a way to ask whether a given account has a master password yet.
|
|
169
204
|
if (!result.ok) return refuse();
|
|
170
205
|
return Response.json(
|
|
171
|
-
{ ok: true, idleMs: lock.idleMs },
|
|
206
|
+
{ ok: true, idleMs: lock.idleMs, accountId: result.accountId },
|
|
172
207
|
{ headers: { ...noStore, "set-cookie": cookie(result.token, secure) } },
|
|
173
208
|
);
|
|
174
209
|
}
|
|
@@ -178,8 +213,12 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
178
213
|
const verifier = typeof body?.verifier === "string" ? body.verifier : "";
|
|
179
214
|
const result = await lock.unlock(verifier);
|
|
180
215
|
if (!result.ok) return refuse(result.retryAfterMs);
|
|
216
|
+
// 🔴 The account id is in the REPLY and not only in the cookie, because the page
|
|
217
|
+
// that just unlocked has to reload into the right tenant. It names the account
|
|
218
|
+
// that opened and never the ones that did not, so a wrong password still learns
|
|
219
|
+
// nothing about what else is here.
|
|
181
220
|
return Response.json(
|
|
182
|
-
{ ok: true, idleMs: lock.idleMs },
|
|
221
|
+
{ ok: true, idleMs: lock.idleMs, accountId: result.accountId },
|
|
183
222
|
{ headers: { ...noStore, "set-cookie": cookie(result.token, secure) } },
|
|
184
223
|
);
|
|
185
224
|
}
|
|
@@ -203,11 +242,46 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
203
242
|
const idleMs = lock.setIdleMs(Number(body?.idleMs));
|
|
204
243
|
return Response.json({ ok: true, idleMs }, { headers: noStore });
|
|
205
244
|
}
|
|
245
|
+
if (path === MASTER_LOCK_PATHS.accounts) {
|
|
246
|
+
const body = await readJson(req);
|
|
247
|
+
const result = await lock.addAccount(req, {
|
|
248
|
+
currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
|
|
249
|
+
verifier: typeof body?.verifier === "string" ? body.verifier : "",
|
|
250
|
+
label: typeof body?.label === "string" ? body.label : "",
|
|
251
|
+
hint: typeof body?.hint === "string" ? body.hint : "",
|
|
252
|
+
});
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
return Response.json({ error: result.reason }, { status: 400, headers: noStore });
|
|
255
|
+
}
|
|
256
|
+
// 🔴 No cookie. Creating a tenant does not enter it — switching means typing that
|
|
257
|
+
// tenant's password, which is the entire boundary this feature is made of.
|
|
258
|
+
return Response.json({ ok: true, account: result.account }, { headers: noStore });
|
|
259
|
+
}
|
|
206
260
|
if (path === MASTER_LOCK_PATHS.change) {
|
|
207
261
|
const body = await readJson(req);
|
|
208
|
-
|
|
262
|
+
/*
|
|
263
|
+
* 🔴 An OLDER client sends a freshly-minted `kdf` with a rotation, because that is
|
|
264
|
+
* what the wire looked like before accounts existed. Ignoring it silently would be
|
|
265
|
+
* a lockout, not a compatibility shim: the verifier in the same body was derived
|
|
266
|
+
* under THAT salt, so storing it would leave a hash nothing can ever match — the
|
|
267
|
+
* owner would set a new password, be logged out by the rotation, and find that
|
|
268
|
+
* neither the old nor the new one opens the app.
|
|
269
|
+
*
|
|
270
|
+
* There is no safe way to honour it either, since every account on this app shares
|
|
271
|
+
* one descriptor. So it is refused, loudly, with the one thing that distinguishes
|
|
272
|
+
* it from a wrong password.
|
|
273
|
+
*/
|
|
274
|
+
if (body?.kdf !== undefined) {
|
|
275
|
+
const supplied = body.kdf as { salt?: unknown };
|
|
276
|
+
if (!lock.kdf || supplied?.salt !== lock.kdf.salt) {
|
|
277
|
+
return Response.json(
|
|
278
|
+
{ error: "stale-client" },
|
|
279
|
+
{ status: 409, headers: noStore },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const result = await lock.change(req, {
|
|
209
284
|
currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
|
|
210
|
-
kdf: body?.kdf as never,
|
|
211
285
|
verifier: typeof body?.verifier === "string" ? body.verifier : "",
|
|
212
286
|
});
|
|
213
287
|
if (!result.ok) {
|
|
@@ -224,6 +298,11 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
224
298
|
}
|
|
225
299
|
|
|
226
300
|
return {
|
|
301
|
+
accountFor(req: Request): string | null {
|
|
302
|
+
// The SAME resolution the guard itself uses, so an app can never be told a
|
|
303
|
+
// different tenant from the one the wall admitted.
|
|
304
|
+
return resolve(req)?.accountFor(req) ?? null;
|
|
305
|
+
},
|
|
227
306
|
async handle(req: Request): Promise<Response | null> {
|
|
228
307
|
const url = new URL(req.url);
|
|
229
308
|
const own =
|
|
@@ -22,7 +22,10 @@ export {
|
|
|
22
22
|
masterLockPrincipalKey,
|
|
23
23
|
} from "./principals";
|
|
24
24
|
export {
|
|
25
|
+
FIRST_ACCOUNT_ID,
|
|
25
26
|
MasterLock,
|
|
27
|
+
type MasterLockAccount,
|
|
28
|
+
type MasterLockAddAccountResult,
|
|
26
29
|
type MasterLockEnrollResult,
|
|
27
30
|
type MasterLockOptions,
|
|
28
31
|
type MasterLockRecord,
|
|
@@ -59,7 +59,7 @@ export function lockPageHtml(options: LockPageOptions): string {
|
|
|
59
59
|
(enroll
|
|
60
60
|
? "You are signed in, but this site needs a master password of your own. " +
|
|
61
61
|
"Choose one now. Nobody else can see it and nobody else can reset it — " +
|
|
62
|
-
"if you forget it, it cannot be recovered."
|
|
62
|
+
"if you forget it, it cannot be recovered, so leave yourself a hint."
|
|
63
63
|
: "This site is locked. Signing in is not enough — enter the master password to continue."),
|
|
64
64
|
);
|
|
65
65
|
return `<!doctype html>
|
|
@@ -93,12 +93,24 @@ export function lockPageHtml(options: LockPageOptions): string {
|
|
|
93
93
|
<input id="ml-confirm" type="password" name="confirm-master-password"
|
|
94
94
|
autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
|
|
95
95
|
minlength="10">
|
|
96
|
+
</label>
|
|
97
|
+
<label class="field">
|
|
98
|
+
<span>Hint <em>optional</em></span>
|
|
99
|
+
<input id="ml-hint" type="text" name="master-password-hint" maxlength="200"
|
|
100
|
+
spellcheck="false" autocomplete="off"
|
|
101
|
+
placeholder="Something only you would understand">
|
|
96
102
|
</label>`
|
|
97
103
|
: ""
|
|
98
104
|
}
|
|
99
105
|
<button id="ml-submit" type="submit">${enroll ? "Set master password" : "Unlock"}</button>
|
|
100
106
|
</form>
|
|
101
|
-
<p id="ml-status" class="status" role="status" aria-live="polite"></p
|
|
107
|
+
<p id="ml-status" class="status" role="status" aria-live="polite"></p>${
|
|
108
|
+
enroll
|
|
109
|
+
? ""
|
|
110
|
+
: `
|
|
111
|
+
<button id="ml-forgot" type="button" class="quiet">I forgot which password</button>
|
|
112
|
+
<dl id="ml-hints" class="hints" hidden></dl>`
|
|
113
|
+
}
|
|
102
114
|
</main>
|
|
103
115
|
<script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
|
|
104
116
|
</body>
|
|
@@ -134,6 +146,16 @@ button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-acc
|
|
|
134
146
|
button[disabled]{opacity:.6;cursor:progress}
|
|
135
147
|
.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}
|
|
136
148
|
.status[data-tone="error"]{color:var(--ml-danger)}
|
|
149
|
+
.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}
|
|
150
|
+
button.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;
|
|
151
|
+
font-size:12.5px;text-decoration:underline;text-underline-offset:3px}
|
|
152
|
+
button.quiet:hover{color:var(--ml-text)}
|
|
153
|
+
button.quiet[disabled]{opacity:.5}
|
|
154
|
+
.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);
|
|
155
|
+
border-radius:10px;background:var(--ml-bg);font-size:12.5px}
|
|
156
|
+
.hints dt{font-weight:650;color:var(--ml-text)}
|
|
157
|
+
.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
|
|
158
|
+
.hints dd:last-child{margin-bottom:0}
|
|
137
159
|
`;
|
|
138
160
|
|
|
139
161
|
/**
|
|
@@ -150,8 +172,11 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
|
|
|
150
172
|
const form = document.getElementById("ml-form");
|
|
151
173
|
const input = document.getElementById("ml-input");
|
|
152
174
|
const confirmInput = document.getElementById("ml-confirm");
|
|
175
|
+
const hintInput = document.getElementById("ml-hint");
|
|
153
176
|
const submit = document.getElementById("ml-submit");
|
|
154
177
|
const status = document.getElementById("ml-status");
|
|
178
|
+
const forgot = document.getElementById("ml-forgot");
|
|
179
|
+
const hints = document.getElementById("ml-hints");
|
|
155
180
|
|
|
156
181
|
function say(message, tone) {
|
|
157
182
|
status.textContent = message;
|
|
@@ -249,7 +274,11 @@ async function doEnroll(password) {
|
|
|
249
274
|
method: "POST",
|
|
250
275
|
credentials: "same-origin",
|
|
251
276
|
headers: { "content-type": "application/json" },
|
|
252
|
-
body: JSON.stringify({
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
kdf: params,
|
|
279
|
+
verifier: verifier,
|
|
280
|
+
hint: hintInput ? hintInput.value : "",
|
|
281
|
+
}),
|
|
253
282
|
});
|
|
254
283
|
if (res.ok) { say("Master password set."); location.reload(); return true; }
|
|
255
284
|
// The server collapses every enrollment refusal into one, so this cannot say more
|
|
@@ -275,6 +304,44 @@ async function doUnlock(password) {
|
|
|
275
304
|
return false;
|
|
276
305
|
}
|
|
277
306
|
|
|
307
|
+
// ── the hints, and why they are behind a click ───────────────────────────────
|
|
308
|
+
// Reading them says how many master passwords this app has and what each is called, which
|
|
309
|
+
// is the one fact the rest of this page is built never to disclose. Fetching them on load
|
|
310
|
+
// would leak it to anyone who merely opened the door; a button makes it something the
|
|
311
|
+
// owner ASKS for. Everyone who gets this far has already passed the app's own sign-in.
|
|
312
|
+
if (forgot && hints) {
|
|
313
|
+
forgot.addEventListener("click", async () => {
|
|
314
|
+
if (!hints.hidden) { hints.hidden = true; return; }
|
|
315
|
+
forgot.disabled = true;
|
|
316
|
+
try {
|
|
317
|
+
const res = await fetch(paths.hints, { credentials: "same-origin" });
|
|
318
|
+
const body = await res.json();
|
|
319
|
+
const rows = (body && body.hints) || [];
|
|
320
|
+
hints.replaceChildren();
|
|
321
|
+
if (rows.length === 0) {
|
|
322
|
+
const dd = document.createElement("dd");
|
|
323
|
+
dd.textContent = "No hints have been written yet.";
|
|
324
|
+
hints.append(dd);
|
|
325
|
+
}
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
const dt = document.createElement("dt");
|
|
328
|
+
dt.textContent = row.label;
|
|
329
|
+
const dd = document.createElement("dd");
|
|
330
|
+
// 🔴 textContent, never innerHTML. The hint is free text the owner typed and this
|
|
331
|
+
// page runs under a CSP that permits no inline script — writing it as markup would
|
|
332
|
+
// be the one way to get some in.
|
|
333
|
+
dd.textContent = row.hint || "— no hint —";
|
|
334
|
+
hints.append(dt, dd);
|
|
335
|
+
}
|
|
336
|
+
hints.hidden = false;
|
|
337
|
+
} catch {
|
|
338
|
+
say("Cannot reach this site right now.", "error");
|
|
339
|
+
} finally {
|
|
340
|
+
forgot.disabled = false;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
278
345
|
form.addEventListener("submit", async (event) => {
|
|
279
346
|
event.preventDefault();
|
|
280
347
|
if (busy || submit.disabled) return;
|
|
@@ -77,10 +77,32 @@ describe("seeding", () => {
|
|
|
77
77
|
expect(lock.configured).toBe(false);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
test("🔴 an UNCONFIGURED app reads as
|
|
80
|
+
test("🔴 an UNCONFIGURED app reads as LOCKED, and offers to take a first password", () => {
|
|
81
|
+
// This INVERTS what it asserted until the accounts model, and the inversion is the
|
|
82
|
+
// owner's ruling: *"if there is no master password then it has to get one set before
|
|
83
|
+
// a user could add anything. It would be booting to nothing until the password is
|
|
84
|
+
// created."* Nothing is bricked, because `enroll` is the way out — which is the
|
|
85
|
+
// half that made the old "unconfigured means open" reasoning necessary.
|
|
81
86
|
const { lock } = build({ seed: null });
|
|
82
87
|
expect(lock.configured).toBe(false);
|
|
88
|
+
expect(lock.unlocked).toBe(false);
|
|
89
|
+
expect(lock.awaitingEnrollment).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("🔴 an unlock is never AMBIENT — a second browser that typed nothing gets no account", async () => {
|
|
93
|
+
// The property the whole tenant model rests on. `unlocked` says somebody's device is
|
|
94
|
+
// open; it may never answer "whose library do I serve", because the answer would be
|
|
95
|
+
// "whichever one is open", and that is how one tenant's files reach a session that
|
|
96
|
+
// holds the other tenant's password.
|
|
97
|
+
const { lock } = build();
|
|
98
|
+
const opened = await lock.unlock(VERIFIER);
|
|
99
|
+
expect(opened.ok).toBe(true);
|
|
83
100
|
expect(lock.unlocked).toBe(true);
|
|
101
|
+
// A request carrying no token at all.
|
|
102
|
+
expect(lock.accountFor(req())).toBeNull();
|
|
103
|
+
expect(lock.presents(req())).toBe(false);
|
|
104
|
+
// An invented one.
|
|
105
|
+
expect(lock.accountFor(req("not-a-real-token"))).toBeNull();
|
|
84
106
|
});
|
|
85
107
|
|
|
86
108
|
test("a seed whose hash arrived shell-quoted is repaired, not refused", async () => {
|
|
@@ -262,24 +284,24 @@ describe("the guess throttle", () => {
|
|
|
262
284
|
});
|
|
263
285
|
|
|
264
286
|
describe("rotation", () => {
|
|
265
|
-
test("needs the current password,
|
|
287
|
+
test("needs the current password, keeps the ACCOUNT, and locks the site", async () => {
|
|
266
288
|
const { lock, store, locked } = build();
|
|
267
|
-
await lock.unlock(VERIFIER);
|
|
289
|
+
const opened = await lock.unlock(VERIFIER);
|
|
290
|
+
const token = opened.ok ? opened.token : "";
|
|
291
|
+
const account = opened.ok ? opened.accountId : "";
|
|
292
|
+
expect(account).toBe("acct-1");
|
|
268
293
|
|
|
269
|
-
const
|
|
270
|
-
const nextVerifier = await deriveMasterLockVerifier("a different one", nextKdf);
|
|
294
|
+
const nextVerifier = await deriveMasterLockVerifier("a different one", KDF);
|
|
271
295
|
|
|
272
|
-
const refused = await lock.change({
|
|
296
|
+
const refused = await lock.change(req(token), {
|
|
273
297
|
currentVerifier: "wrong",
|
|
274
|
-
kdf: nextKdf,
|
|
275
298
|
verifier: nextVerifier,
|
|
276
299
|
});
|
|
277
300
|
expect(refused).toEqual({ ok: false, reason: "wrong" });
|
|
278
301
|
expect(lock.unlocked).toBe(true);
|
|
279
302
|
|
|
280
|
-
const done = await lock.change({
|
|
303
|
+
const done = await lock.change(req(token), {
|
|
281
304
|
currentVerifier: VERIFIER,
|
|
282
|
-
kdf: nextKdf,
|
|
283
305
|
verifier: nextVerifier,
|
|
284
306
|
});
|
|
285
307
|
expect(done).toEqual({ ok: true });
|
|
@@ -287,36 +309,47 @@ describe("rotation", () => {
|
|
|
287
309
|
expect(lock.unlocked).toBe(false);
|
|
288
310
|
expect(locked.at(-1)).toBe(true);
|
|
289
311
|
|
|
290
|
-
// The OLD one no longer opens it; the new one does.
|
|
312
|
+
// The OLD one no longer opens it; the new one does — and opens the SAME account.
|
|
291
313
|
expect((await lock.unlock(VERIFIER)).ok).toBe(false);
|
|
292
|
-
|
|
293
|
-
expect(
|
|
314
|
+
const again = await lock.unlock(nextVerifier);
|
|
315
|
+
expect(again.ok && again.accountId).toBe("acct-1");
|
|
316
|
+
// 🔴 The salt did NOT move. Every account derives under one app-wide descriptor, so
|
|
317
|
+
// a rotation that minted a fresh one would invalidate every sibling's stored hash.
|
|
318
|
+
expect(readRecord(store.read())?.kdf.salt).toBe(KDF.salt);
|
|
319
|
+
}, KDF_BULK_MS);
|
|
320
|
+
|
|
321
|
+
test("🔴 a rotation is refused outright without a live unlock of the account it names", async () => {
|
|
322
|
+
// An open session is not proof the owner is at the keyboard, and a caller with no
|
|
323
|
+
// session at all has not even got that far. There is no account to rotate.
|
|
324
|
+
const { lock, store } = build();
|
|
325
|
+
const before = store.read();
|
|
326
|
+
const result = await lock.change(req(), { currentVerifier: VERIFIER, verifier: "x" });
|
|
327
|
+
expect(result).toEqual({ ok: false, reason: "locked" });
|
|
328
|
+
expect(store.read()).toBe(before);
|
|
294
329
|
});
|
|
295
330
|
|
|
296
|
-
test("refuses
|
|
331
|
+
test("refuses an empty verifier before touching the record", async () => {
|
|
297
332
|
const { lock, store } = build();
|
|
333
|
+
const opened = await lock.unlock(VERIFIER);
|
|
298
334
|
const before = store.read();
|
|
299
|
-
const result = await lock.change({
|
|
335
|
+
const result = await lock.change(req(opened.ok ? opened.token : ""), {
|
|
300
336
|
currentVerifier: VERIFIER,
|
|
301
|
-
|
|
302
|
-
verifier: "x",
|
|
337
|
+
verifier: "",
|
|
303
338
|
});
|
|
304
339
|
expect(result).toEqual({ ok: false, reason: "invalid" });
|
|
305
340
|
expect(store.read()).toBe(before);
|
|
306
|
-
});
|
|
341
|
+
}, KDF_BULK_MS);
|
|
307
342
|
|
|
308
343
|
test("keeps this app's idle timeout across a rotation", async () => {
|
|
309
344
|
const { lock, store } = build();
|
|
310
345
|
lock.setIdleMs(120_000);
|
|
311
|
-
await lock.unlock(VERIFIER);
|
|
312
|
-
|
|
313
|
-
await lock.change({
|
|
346
|
+
const opened = await lock.unlock(VERIFIER);
|
|
347
|
+
await lock.change(req(opened.ok ? opened.token : ""), {
|
|
314
348
|
currentVerifier: VERIFIER,
|
|
315
|
-
|
|
316
|
-
verifier: await deriveMasterLockVerifier("next", nextKdf),
|
|
349
|
+
verifier: await deriveMasterLockVerifier("next", KDF),
|
|
317
350
|
});
|
|
318
351
|
expect(readRecord(store.read())?.idleMs).toBe(120_000);
|
|
319
|
-
});
|
|
352
|
+
}, KDF_BULK_MS);
|
|
320
353
|
});
|
|
321
354
|
|
|
322
355
|
describe("readRecord", () => {
|