cursedbelt-server 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/master-lock/guard.d.ts +10 -0
- package/dist/server/master-lock/guard.js +70 -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/master-lock/accounts.spec.ts +308 -0
- package/src/server/master-lock/guard.spec.ts +69 -7
- package/src/server/master-lock/guard.ts +78 -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
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
* an env var that pre-set someone's password would be exactly the "one credential opens
|
|
39
39
|
* everybody" shape this replaces. A principal with no record is `awaitingEnrollment` —
|
|
40
40
|
* locked, and offered the choose-a-password form.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 That last sentence used to need an `enrollable: true` flag, and does not any more.
|
|
43
|
+
* Failing closed with an enrollment form is now what EVERY lock does when it holds no
|
|
44
|
+
* account (see `masterLock.ts`'s `unlocked`), so the flag that used to separate the two
|
|
45
|
+
* modes has nothing left to decide. What still makes this file per-PERSON is the key each
|
|
46
|
+
* record is stored under, which is the only thing it ever really was.
|
|
41
47
|
*/
|
|
42
48
|
import { createHash } from "node:crypto";
|
|
43
49
|
import { MasterLock, readRecord } from "./masterLock";
|
|
@@ -86,7 +92,6 @@ export class MasterLockDirectory {
|
|
|
86
92
|
};
|
|
87
93
|
const lock = new MasterLock({
|
|
88
94
|
store,
|
|
89
|
-
enrollable: true,
|
|
90
95
|
...(this.options.now ? { now: this.options.now } : {}),
|
|
91
96
|
...(this.options.log
|
|
92
97
|
? { log: (line) => this.options.log?.(`[${principalId}] ${line}`) }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MasterLockKdfParams } from "cursedbelt-core/master-lock/kdf";
|
|
2
|
-
import type
|
|
2
|
+
import { type MasterLockRecord } from "./masterLock";
|
|
3
3
|
/**
|
|
4
4
|
* The idle window a freshly minted lock starts with.
|
|
5
5
|
*
|
|
@@ -17,6 +17,10 @@ export interface MintMasterLockSeedOptions {
|
|
|
17
17
|
idleMs?: number;
|
|
18
18
|
/** Reuse existing params instead of generating a salt — for a rotation. */
|
|
19
19
|
kdf?: MasterLockKdfParams;
|
|
20
|
+
/** What to call the tenant this seed creates. Defaults to `Account 1`. */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** The owner's reminder for this password, shown on the lock page's "I forgot". */
|
|
23
|
+
hint?: string;
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
22
26
|
* `password` → the record an app can be seeded with.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { DEFAULT_IDLE_MS } from "cursedbelt-core/master-lock/policy";
|
|
26
26
|
import { deriveMasterLockVerifier, newMasterLockKdfParams, } from "cursedbelt-core/master-lock/kdf";
|
|
27
|
+
import { FIRST_ACCOUNT_ID } from "./masterLock";
|
|
27
28
|
/**
|
|
28
29
|
* The idle window a freshly minted lock starts with.
|
|
29
30
|
*
|
|
@@ -52,8 +53,24 @@ export async function mintMasterLockSeed(password, options = {}) {
|
|
|
52
53
|
const verifier = await deriveMasterLockVerifier(password, kdf);
|
|
53
54
|
return {
|
|
54
55
|
kdf,
|
|
55
|
-
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
56
56
|
idleMs: options.idleMs ?? DEFAULT_SEED_IDLE_MS,
|
|
57
|
+
/*
|
|
58
|
+
* 🔴 ONE account, and its id is `FIRST_ACCOUNT_ID` rather than anything derived
|
|
59
|
+
* from `options`. A seed is what an app boots with before anybody has typed
|
|
60
|
+
* anything, so the tenant it names is the tenant whose rows the app already holds —
|
|
61
|
+
* and `readRecord` maps the legacy single-password shape onto the same id for
|
|
62
|
+
* exactly that reason. Two different "first" ids would give a seeded app and a
|
|
63
|
+
* migrated app different answers to "whose library is this".
|
|
64
|
+
*/
|
|
65
|
+
accounts: [
|
|
66
|
+
{
|
|
67
|
+
id: FIRST_ACCOUNT_ID,
|
|
68
|
+
label: options.label ?? "Account 1",
|
|
69
|
+
hint: options.hint ?? "",
|
|
70
|
+
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
71
|
+
createdAt: 0,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
57
74
|
};
|
|
58
75
|
}
|
|
59
76
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -200,7 +200,7 @@
|
|
|
200
200
|
}
|
|
201
201
|
},
|
|
202
202
|
"dependencies": {
|
|
203
|
-
"cursedbelt-core": "^
|
|
203
|
+
"cursedbelt-core": "^2.0.0",
|
|
204
204
|
"cwip": "^4.1.0",
|
|
205
205
|
"jose": "^6.2.3"
|
|
206
206
|
},
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 **The accounts model — a master password IS a tenant.**
|
|
3
|
+
*
|
|
4
|
+
* The owner's words, and the whole specification:
|
|
5
|
+
*
|
|
6
|
+
* > *"If I login with a master password I only see the files I added or removed with that
|
|
7
|
+
* > login. If I login with my other password I have not made yet I only see the other ones
|
|
8
|
+
* > I add in that login. The master passwords represent their own tenants or you can think
|
|
9
|
+
* > of it as a fully separate account even though I am the user in both cases."*
|
|
10
|
+
*
|
|
11
|
+
* This file proves the half that lives in the lock. The half that lives in an app — every
|
|
12
|
+
* query scoped by the id this hands out — is that app's own gate to prove.
|
|
13
|
+
*
|
|
14
|
+
* The claim each `describe` carries is the thing that would be catastrophic to get wrong,
|
|
15
|
+
* and three of them are catastrophic in a way that is SILENT: a tenant that cannot be
|
|
16
|
+
* reached looks exactly like a tenant with nothing in it.
|
|
17
|
+
*/
|
|
18
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import {
|
|
20
|
+
type MasterLockKdfParams,
|
|
21
|
+
deriveMasterLockVerifier,
|
|
22
|
+
} from "cursedbelt-core/master-lock";
|
|
23
|
+
import { FIRST_ACCOUNT_ID, MasterLock, readRecord } from "./masterLock";
|
|
24
|
+
import { createMemoryMasterLockStore } from "./store";
|
|
25
|
+
|
|
26
|
+
/** `iter: 1` — the ROUND COUNT is pinned in `cursedbelt-core`'s `kdf.spec.ts`; 600k × N
|
|
27
|
+
* argon2 verifies here would be minutes of gate for nothing this file is testing. */
|
|
28
|
+
const KDF: MasterLockKdfParams = {
|
|
29
|
+
v: 1,
|
|
30
|
+
alg: "PBKDF2-SHA256",
|
|
31
|
+
iter: 1,
|
|
32
|
+
salt: "AAECAwQFBgcICQoLDA0ODw",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const FIRST = "the owner's vault master password";
|
|
36
|
+
const SECOND = "password";
|
|
37
|
+
|
|
38
|
+
let firstVerifier = "";
|
|
39
|
+
let secondVerifier = "";
|
|
40
|
+
/** The LEGACY record: one password, no accounts — the shape every `MASTER_LOCK_SEED` in
|
|
41
|
+
* the fleet carries, and the shape every un-upgraded app already has in its database. */
|
|
42
|
+
let legacyJson = "";
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
if (firstVerifier) return;
|
|
46
|
+
firstVerifier = await deriveMasterLockVerifier(FIRST, KDF);
|
|
47
|
+
secondVerifier = await deriveMasterLockVerifier(SECOND, KDF);
|
|
48
|
+
legacyJson = JSON.stringify({
|
|
49
|
+
kdf: KDF,
|
|
50
|
+
verifierHash: await Bun.password.hash(firstVerifier, { algorithm: "argon2id" }),
|
|
51
|
+
idleMs: 300_000,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const build = (stored?: string | null) => {
|
|
56
|
+
const store = createMemoryMasterLockStore(stored ?? null);
|
|
57
|
+
return { store, lock: new MasterLock({ store }) };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const req = (token: string): Request =>
|
|
61
|
+
new Request("http://app.test/", { headers: { cookie: `master_lock=${token}` } });
|
|
62
|
+
|
|
63
|
+
/** Unlock and hand back a request that presents the resulting token. */
|
|
64
|
+
async function open(lock: MasterLock, verifier: string) {
|
|
65
|
+
const result = await lock.unlock(verifier);
|
|
66
|
+
if (!result.ok) throw new Error("expected the unlock to succeed");
|
|
67
|
+
return { ...result, req: req(result.token) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("🔴 claim 1 — the owner's existing password keeps opening everything he has", () => {
|
|
71
|
+
test("a legacy single-password record becomes acct-1, and that password still works", async () => {
|
|
72
|
+
// THE migration guarantee. `apps/collections` stamps its 279 existing rows with this
|
|
73
|
+
// id, so an id that moved — or a record that failed to parse and armed nothing —
|
|
74
|
+
// would orphan the entire library this migration exists to carry across.
|
|
75
|
+
const { lock } = build(legacyJson);
|
|
76
|
+
expect(lock.configured).toBe(true);
|
|
77
|
+
expect(lock.accounts.length).toBe(1);
|
|
78
|
+
expect(lock.accounts[0]?.id).toBe(FIRST_ACCOUNT_ID);
|
|
79
|
+
|
|
80
|
+
const opened = await open(lock, firstVerifier);
|
|
81
|
+
expect(opened.accountId).toBe(FIRST_ACCOUNT_ID);
|
|
82
|
+
// And nothing else was invented along the way.
|
|
83
|
+
expect(lock.idleMs).toBe(300_000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the migration is a READ — it does not rewrite the row until something else does", async () => {
|
|
87
|
+
// A record that rewrote itself on boot would mean a rollback to the previous build
|
|
88
|
+
// could no longer read its own store. Nothing is persisted until a write happens for
|
|
89
|
+
// a reason of its own.
|
|
90
|
+
const { lock, store } = build(legacyJson);
|
|
91
|
+
expect(lock.configured).toBe(true);
|
|
92
|
+
expect(store.read()).toBe(legacyJson);
|
|
93
|
+
|
|
94
|
+
lock.setIdleMs(600_000);
|
|
95
|
+
const rewritten = readRecord(store.read());
|
|
96
|
+
expect(rewritten?.accounts[0]?.id).toBe(FIRST_ACCOUNT_ID);
|
|
97
|
+
expect(rewritten?.idleMs).toBe(600_000);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("🔴 claim 2 — a second password is a second tenant, and never the first one", () => {
|
|
102
|
+
test("adding one needs a live unlock AND the current password, re-typed", async () => {
|
|
103
|
+
const { lock } = build(legacyJson);
|
|
104
|
+
|
|
105
|
+
// No unlock at all.
|
|
106
|
+
expect(
|
|
107
|
+
await lock.addAccount(req("nope"), {
|
|
108
|
+
currentVerifier: firstVerifier,
|
|
109
|
+
verifier: secondVerifier,
|
|
110
|
+
label: "Account 2",
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({ ok: false, reason: "locked" });
|
|
113
|
+
|
|
114
|
+
const opened = await open(lock, firstVerifier);
|
|
115
|
+
|
|
116
|
+
// Unlocked, but the current password not proved.
|
|
117
|
+
expect(
|
|
118
|
+
await lock.addAccount(opened.req, {
|
|
119
|
+
currentVerifier: "not it",
|
|
120
|
+
verifier: secondVerifier,
|
|
121
|
+
label: "Account 2",
|
|
122
|
+
}),
|
|
123
|
+
).toEqual({ ok: false, reason: "wrong" });
|
|
124
|
+
expect(lock.accounts.length).toBe(1);
|
|
125
|
+
|
|
126
|
+
const added = await lock.addAccount(opened.req, {
|
|
127
|
+
currentVerifier: firstVerifier,
|
|
128
|
+
verifier: secondVerifier,
|
|
129
|
+
label: "Account 2",
|
|
130
|
+
hint: "the obvious one",
|
|
131
|
+
});
|
|
132
|
+
expect(added.ok && added.account.id).toBe("acct-2");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("each password opens its OWN account, and a token never reads as the other", async () => {
|
|
136
|
+
const { lock } = build(legacyJson);
|
|
137
|
+
const first = await open(lock, firstVerifier);
|
|
138
|
+
await lock.addAccount(first.req, {
|
|
139
|
+
currentVerifier: firstVerifier,
|
|
140
|
+
verifier: secondVerifier,
|
|
141
|
+
label: "Account 2",
|
|
142
|
+
});
|
|
143
|
+
const second = await open(lock, secondVerifier);
|
|
144
|
+
|
|
145
|
+
expect(first.accountId).toBe("acct-1");
|
|
146
|
+
expect(second.accountId).toBe("acct-2");
|
|
147
|
+
// The property everything else rests on: a token is bound to the account that minted
|
|
148
|
+
// it, so two browsers open at once are two tenants, not one ambient state.
|
|
149
|
+
expect(lock.accountFor(first.req)).toBe("acct-1");
|
|
150
|
+
expect(lock.accountFor(second.req)).toBe("acct-2");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("creating a tenant does NOT enter it", async () => {
|
|
154
|
+
// Switching means typing that tenant's password. If creating one dropped you into
|
|
155
|
+
// it, the owner would add an account and watch his library apparently vanish.
|
|
156
|
+
const { lock } = build(legacyJson);
|
|
157
|
+
const first = await open(lock, firstVerifier);
|
|
158
|
+
await lock.addAccount(first.req, {
|
|
159
|
+
currentVerifier: firstVerifier,
|
|
160
|
+
verifier: secondVerifier,
|
|
161
|
+
label: "Account 2",
|
|
162
|
+
});
|
|
163
|
+
expect(lock.accountFor(first.req)).toBe("acct-1");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("🔴 a password that already opens something is refused", async () => {
|
|
167
|
+
// Two accounts under one password would make `unlock` serve whichever the loop
|
|
168
|
+
// reached first — so the owner would type the password he has always typed and be
|
|
169
|
+
// handed an empty library, with his real one apparently gone.
|
|
170
|
+
const { lock } = build(legacyJson);
|
|
171
|
+
const first = await open(lock, firstVerifier);
|
|
172
|
+
expect(
|
|
173
|
+
await lock.addAccount(first.req, {
|
|
174
|
+
currentVerifier: firstVerifier,
|
|
175
|
+
verifier: firstVerifier,
|
|
176
|
+
label: "A clone",
|
|
177
|
+
}),
|
|
178
|
+
).toEqual({ ok: false, reason: "duplicate" });
|
|
179
|
+
expect(lock.accounts.length).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("🔴 claim 3 — changing a password never changes which content it opens", () => {
|
|
184
|
+
test("the account id survives a rotation, and the siblings survive it too", async () => {
|
|
185
|
+
// The owner: *"If I change account 2 password it just changes how I get the access.
|
|
186
|
+
// For instance I could change it from 'password' to 'passwordIPicked' later and I
|
|
187
|
+
// would still access the same content afterward."*
|
|
188
|
+
const { lock } = build(legacyJson);
|
|
189
|
+
const first = await open(lock, firstVerifier);
|
|
190
|
+
await lock.addAccount(first.req, {
|
|
191
|
+
currentVerifier: firstVerifier,
|
|
192
|
+
verifier: secondVerifier,
|
|
193
|
+
label: "Account 2",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const second = await open(lock, secondVerifier);
|
|
197
|
+
const picked = await deriveMasterLockVerifier("passwordIPicked", KDF);
|
|
198
|
+
expect(
|
|
199
|
+
await lock.change(second.req, { currentVerifier: secondVerifier, verifier: picked }),
|
|
200
|
+
).toEqual({ ok: true });
|
|
201
|
+
|
|
202
|
+
// The new password opens the SAME tenant…
|
|
203
|
+
const reopened = await open(lock, picked);
|
|
204
|
+
expect(reopened.accountId).toBe("acct-2");
|
|
205
|
+
// …the old one opens nothing…
|
|
206
|
+
expect((await lock.unlock(secondVerifier)).ok).toBe(false);
|
|
207
|
+
// …and account 1 is untouched, which is what a shared KDF salt is protecting.
|
|
208
|
+
const stillFirst = await open(lock, firstVerifier);
|
|
209
|
+
expect(stillFirst.accountId).toBe("acct-1");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a rotation cannot collide a password onto a sibling", async () => {
|
|
213
|
+
const { lock } = build(legacyJson);
|
|
214
|
+
const first = await open(lock, firstVerifier);
|
|
215
|
+
await lock.addAccount(first.req, {
|
|
216
|
+
currentVerifier: firstVerifier,
|
|
217
|
+
verifier: secondVerifier,
|
|
218
|
+
label: "Account 2",
|
|
219
|
+
});
|
|
220
|
+
const second = await open(lock, secondVerifier);
|
|
221
|
+
expect(
|
|
222
|
+
await lock.change(second.req, {
|
|
223
|
+
currentVerifier: secondVerifier,
|
|
224
|
+
verifier: firstVerifier,
|
|
225
|
+
}),
|
|
226
|
+
).toEqual({ ok: false, reason: "invalid" });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("🔴 claim 4 — the hints are readable while locked, and nothing else is", () => {
|
|
231
|
+
test("a hint survives a rotation and can be rewritten without the password", async () => {
|
|
232
|
+
const { lock } = build(legacyJson);
|
|
233
|
+
const first = await open(lock, firstVerifier);
|
|
234
|
+
await lock.addAccount(first.req, {
|
|
235
|
+
currentVerifier: firstVerifier,
|
|
236
|
+
verifier: secondVerifier,
|
|
237
|
+
label: "Account 2",
|
|
238
|
+
hint: "the obvious one",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(lock.hints()).toEqual([
|
|
242
|
+
{ label: "Account 1", hint: "" },
|
|
243
|
+
{ label: "Account 2", hint: "the obvious one" },
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
const second = await open(lock, secondVerifier);
|
|
247
|
+
expect(lock.editAccount(second.req, { id: "acct-2", hint: "rhymes with lassword" })).toBe(true);
|
|
248
|
+
expect(lock.hints()[1]?.hint).toBe("rhymes with lassword");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("🔴 one session cannot rewrite another tenant's label or hint", async () => {
|
|
252
|
+
// The hint is the one field designed to be read by somebody who is LOCKED OUT, so a
|
|
253
|
+
// session holding one password must not be able to rewrite the note that gets the
|
|
254
|
+
// other one back in.
|
|
255
|
+
const { lock } = build(legacyJson);
|
|
256
|
+
const first = await open(lock, firstVerifier);
|
|
257
|
+
await lock.addAccount(first.req, {
|
|
258
|
+
currentVerifier: firstVerifier,
|
|
259
|
+
verifier: secondVerifier,
|
|
260
|
+
label: "Account 2",
|
|
261
|
+
hint: "the obvious one",
|
|
262
|
+
});
|
|
263
|
+
expect(lock.editAccount(first.req, { id: "acct-2", hint: "overwritten" })).toBe(false);
|
|
264
|
+
expect(lock.hints()[1]?.hint).toBe("the obvious one");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("a hint is trimmed and capped, so it cannot become a wall of text on the lock screen", async () => {
|
|
268
|
+
const { lock } = build(legacyJson);
|
|
269
|
+
const first = await open(lock, firstVerifier);
|
|
270
|
+
await lock.addAccount(first.req, {
|
|
271
|
+
currentVerifier: firstVerifier,
|
|
272
|
+
verifier: secondVerifier,
|
|
273
|
+
label: " Account\n\n2 ",
|
|
274
|
+
hint: `${"x".repeat(400)}`,
|
|
275
|
+
});
|
|
276
|
+
expect(lock.accounts[1]?.label).toBe("Account 2");
|
|
277
|
+
expect(lock.accounts[1]?.hint.length).toBe(200);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("🔴 claim 5 — a malformed row costs one account, never the keyring", () => {
|
|
282
|
+
test("an unreadable account is dropped and the others still open", async () => {
|
|
283
|
+
// This record is the ONLY thing that says which password opens which library.
|
|
284
|
+
// Refusing the whole file over one bad row would take every tenant down with it.
|
|
285
|
+
const good = await Bun.password.hash(firstVerifier, { algorithm: "argon2id" });
|
|
286
|
+
const stored = JSON.stringify({
|
|
287
|
+
kdf: KDF,
|
|
288
|
+
idleMs: 300_000,
|
|
289
|
+
accounts: [
|
|
290
|
+
{ id: "acct-1", label: "Account 1", hint: "", verifierHash: good, createdAt: 1 },
|
|
291
|
+
{ id: "acct-2", label: "Broken", hint: "", verifierHash: "not-a-hash", createdAt: 2 },
|
|
292
|
+
// A duplicate id, and an id shaped like something that could reach SQL.
|
|
293
|
+
{ id: "acct-1", label: "Dupe", hint: "", verifierHash: good, createdAt: 3 },
|
|
294
|
+
{ id: "../escape", label: "Nope", hint: "", verifierHash: good, createdAt: 4 },
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
const { lock } = build(stored);
|
|
298
|
+
expect(lock.accounts.map((account) => account.id)).toEqual(["acct-1"]);
|
|
299
|
+
expect((await open(lock, firstVerifier)).accountId).toBe("acct-1");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("an empty keyring is a legitimate state, not a parse failure", async () => {
|
|
303
|
+
const { lock } = build(JSON.stringify({ kdf: KDF, idleMs: 300_000, accounts: [] }));
|
|
304
|
+
expect(lock.configured).toBe(false);
|
|
305
|
+
expect(lock.awaitingEnrollment).toBe(true);
|
|
306
|
+
expect(lock.unlocked).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -150,10 +150,52 @@ describe("while locked", () => {
|
|
|
150
150
|
idleMs: 5 * 60_000,
|
|
151
151
|
remainingMs: 0,
|
|
152
152
|
retryAfterMs: 0,
|
|
153
|
+
// Locked, so there is no account to name.
|
|
154
|
+
accountId: null,
|
|
155
|
+
accountLabel: null,
|
|
153
156
|
});
|
|
154
157
|
expect(JSON.stringify(body)).not.toContain("argon2");
|
|
155
158
|
});
|
|
156
159
|
|
|
160
|
+
test("🔴 status never says how many accounts this app has", async () => {
|
|
161
|
+
// The lock screen must read identically whether the owner keeps one tenant or ten:
|
|
162
|
+
// the number of master passwords is itself a fact worth hiding, and `status` is
|
|
163
|
+
// served to anyone who got past the app's own sign-in, before they prove anything
|
|
164
|
+
// else. `hints` is the one surface that discloses it, behind a deliberate click.
|
|
165
|
+
const { lock, guard } = build();
|
|
166
|
+
const cookie = await open(guard);
|
|
167
|
+
const added = await guard.handle(
|
|
168
|
+
post(
|
|
169
|
+
MASTER_LOCK_PATHS.accounts,
|
|
170
|
+
{
|
|
171
|
+
currentVerifier: VERIFIER,
|
|
172
|
+
verifier: await deriveMasterLockVerifier("second tenant", KDF),
|
|
173
|
+
label: "Second",
|
|
174
|
+
},
|
|
175
|
+
cookie,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
expect(added?.status).toBe(200);
|
|
179
|
+
expect(lock.accounts.length).toBe(2);
|
|
180
|
+
|
|
181
|
+
const res = await guard.handle(xhr(MASTER_LOCK_PATHS.status));
|
|
182
|
+
const body = (await res?.json()) as Record<string, unknown>;
|
|
183
|
+
expect(Object.keys(body).sort()).toEqual([
|
|
184
|
+
"accountId",
|
|
185
|
+
"accountLabel",
|
|
186
|
+
"configured",
|
|
187
|
+
"enrollable",
|
|
188
|
+
"idleMs",
|
|
189
|
+
"kdf",
|
|
190
|
+
"locked",
|
|
191
|
+
"remainingMs",
|
|
192
|
+
"retryAfterMs",
|
|
193
|
+
]);
|
|
194
|
+
const serialized = JSON.stringify(body);
|
|
195
|
+
expect(serialized).not.toContain("Second");
|
|
196
|
+
expect(serialized).not.toContain("acct-2");
|
|
197
|
+
});
|
|
198
|
+
|
|
157
199
|
test("an `allow` path is passed through — that is where a machine surface lives", async () => {
|
|
158
200
|
const { guard } = build((url) => url.pathname === "/healthz");
|
|
159
201
|
expect(await guard.handle(xhr("/healthz"))).toBeNull();
|
|
@@ -249,11 +291,12 @@ describe("while unlocked", () => {
|
|
|
249
291
|
test("rotation needs the current password and locks the site behind it", async () => {
|
|
250
292
|
const { guard } = build();
|
|
251
293
|
const cookie = await open(guard);
|
|
252
|
-
|
|
253
|
-
|
|
294
|
+
// 🔴 Derived under the app's OWN params, not a fresh set. Every account shares one
|
|
295
|
+
// descriptor, so a rotation that minted a new salt would invalidate the others.
|
|
296
|
+
const nextVerifier = await deriveMasterLockVerifier("something else", KDF);
|
|
254
297
|
|
|
255
298
|
const refused = await guard.handle(
|
|
256
|
-
post(MASTER_LOCK_PATHS.change, { currentVerifier: "no",
|
|
299
|
+
post(MASTER_LOCK_PATHS.change, { currentVerifier: "no", verifier: nextVerifier }, cookie),
|
|
257
300
|
);
|
|
258
301
|
expect(refused?.status).toBe(400);
|
|
259
302
|
expect(await refused?.json()).toEqual({ error: "wrong" });
|
|
@@ -261,7 +304,7 @@ describe("while unlocked", () => {
|
|
|
261
304
|
const done = await guard.handle(
|
|
262
305
|
post(
|
|
263
306
|
MASTER_LOCK_PATHS.change,
|
|
264
|
-
{ currentVerifier: VERIFIER,
|
|
307
|
+
{ currentVerifier: VERIFIER, verifier: nextVerifier },
|
|
265
308
|
cookie,
|
|
266
309
|
),
|
|
267
310
|
);
|
|
@@ -272,12 +315,31 @@ describe("while unlocked", () => {
|
|
|
272
315
|
});
|
|
273
316
|
|
|
274
317
|
describe("an app with no master password configured", () => {
|
|
275
|
-
test("
|
|
318
|
+
test("🔴 boots to NOTHING, and offers to take a first password", async () => {
|
|
319
|
+
// This INVERTS what it asserted until the accounts model. "Unconfigured means open"
|
|
320
|
+
// was right while the lock only decided whether a UI was visible — failing closed
|
|
321
|
+
// would have bricked an app nobody could type a password into. Both halves of that
|
|
322
|
+
// reasoning are gone: `enroll` means an unconfigured app offers to take one, and the
|
|
323
|
+
// password now decides WHOSE DATA the app serves, so "open with no password" has no
|
|
324
|
+
// tenant it could answer with. The owner ruled the same way: *"It would be booting
|
|
325
|
+
// to nothing until the password is created."*
|
|
276
326
|
const lock = new MasterLock({ store: createMemoryMasterLockStore(), seedJson: null });
|
|
277
327
|
const guard = createMasterLockGuard({ lock, appLabel: "Test App" });
|
|
278
|
-
|
|
328
|
+
|
|
329
|
+
const page = await guard.handle(doc("/"));
|
|
330
|
+
expect(page?.status).toBe(200);
|
|
331
|
+
expect(await page?.text()).toContain("master password");
|
|
332
|
+
// And no tenant is invented for a caller who has typed nothing.
|
|
333
|
+
expect(guard.accountFor(doc("/"))).toBeNull();
|
|
334
|
+
|
|
279
335
|
const status = await guard.handle(xhr(MASTER_LOCK_PATHS.status));
|
|
280
|
-
expect(await status?.json()).toMatchObject({
|
|
336
|
+
expect(await status?.json()).toMatchObject({
|
|
337
|
+
configured: false,
|
|
338
|
+
locked: true,
|
|
339
|
+
enrollable: true,
|
|
340
|
+
kdf: null,
|
|
341
|
+
accountId: null,
|
|
342
|
+
});
|
|
281
343
|
});
|
|
282
344
|
});
|
|
283
345
|
|
|
@@ -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,25 @@ 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
|
-
const result = await lock.change({
|
|
262
|
+
const result = await lock.change(req, {
|
|
209
263
|
currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
|
|
210
|
-
kdf: body?.kdf as never,
|
|
211
264
|
verifier: typeof body?.verifier === "string" ? body.verifier : "",
|
|
212
265
|
});
|
|
213
266
|
if (!result.ok) {
|
|
@@ -224,6 +277,11 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
|
|
|
224
277
|
}
|
|
225
278
|
|
|
226
279
|
return {
|
|
280
|
+
accountFor(req: Request): string | null {
|
|
281
|
+
// The SAME resolution the guard itself uses, so an app can never be told a
|
|
282
|
+
// different tenant from the one the wall admitted.
|
|
283
|
+
return resolve(req)?.accountFor(req) ?? null;
|
|
284
|
+
},
|
|
227
285
|
async handle(req: Request): Promise<Response | null> {
|
|
228
286
|
const url = new URL(req.url);
|
|
229
287
|
const own =
|