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
|
@@ -112,12 +112,22 @@ describe("claim 1 — one person's unlock never opens another's", () => {
|
|
|
112
112
|
await enroll(OWNER, "the owner's own password");
|
|
113
113
|
const guestPassword = "a different password entirely";
|
|
114
114
|
await enroll(GUEST, guestPassword);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// The rotation needs the owner's OWN live unlock — an open session is not proof
|
|
116
|
+
// somebody is at the keyboard, and per person it is not even proof WHICH person.
|
|
117
|
+
const ownerKdf = directory.for(OWNER).kdf as MasterLockKdfParams;
|
|
118
|
+
const ownerVerifier = await deriveMasterLockVerifier("the owner's own password", ownerKdf);
|
|
119
|
+
directory.for(OWNER).lock();
|
|
120
|
+
const ownerOpen = await directory.for(OWNER).unlock(ownerVerifier);
|
|
121
|
+
expect(ownerOpen.ok).toBe(true);
|
|
122
|
+
const rotated = await directory.for(OWNER).change(
|
|
123
|
+
new Request("https://orch.example/", {
|
|
124
|
+
headers: { cookie: `master_lock=${ownerOpen.ok ? ownerOpen.token : ""}` },
|
|
125
|
+
}),
|
|
126
|
+
{
|
|
127
|
+
currentVerifier: ownerVerifier,
|
|
128
|
+
verifier: await deriveMasterLockVerifier("something new", ownerKdf),
|
|
129
|
+
},
|
|
130
|
+
);
|
|
121
131
|
expect(rotated.ok).toBe(true);
|
|
122
132
|
// The guest's still opens on the guest's unchanged password.
|
|
123
133
|
directory.for(GUEST).lock();
|
|
@@ -191,11 +201,17 @@ describe("claim 2 — signed in is never enough", () => {
|
|
|
191
201
|
expect(fresh.awaitingEnrollment).toBe(true);
|
|
192
202
|
});
|
|
193
203
|
|
|
194
|
-
test("the app-wide
|
|
204
|
+
test("🔴 the app-wide default now MATCHES it — there are not two answers any more", () => {
|
|
205
|
+
// This test used to assert the opposite, and the two modes disagreeing was the whole
|
|
206
|
+
// reason `enrollable` existed. The accounts model settles it in the per-person
|
|
207
|
+
// direction for every app, on the owner's ruling: *"if there is no master password
|
|
208
|
+
// then it has to get one set before a user could add anything."* Nothing is bricked,
|
|
209
|
+
// because enrollment is the way out of the state in both cases — which is exactly
|
|
210
|
+
// what made failing closed affordable here and unaffordable there.
|
|
195
211
|
const appWide = new MasterLock({ store: createMemoryMasterLockStore(null) });
|
|
196
212
|
expect(appWide.configured).toBe(false);
|
|
197
|
-
expect(appWide.unlocked).toBe(
|
|
198
|
-
expect(appWide.awaitingEnrollment).toBe(
|
|
213
|
+
expect(appWide.unlocked).toBe(false);
|
|
214
|
+
expect(appWide.awaitingEnrollment).toBe(true);
|
|
199
215
|
});
|
|
200
216
|
|
|
201
217
|
test("the guard refuses every app path for an un-enrolled principal", async () => {
|
|
@@ -246,21 +262,35 @@ describe("claim 3 — enroll only ever creates the password that was never set",
|
|
|
246
262
|
const key = "shared";
|
|
247
263
|
return { read: () => kvv.get(key), write: (json) => kvv.set(key, json) };
|
|
248
264
|
})();
|
|
249
|
-
const a = new MasterLock({ store
|
|
250
|
-
const b = new MasterLock({ store
|
|
265
|
+
const a = new MasterLock({ store });
|
|
266
|
+
const b = new MasterLock({ store });
|
|
251
267
|
const first = await a.enroll({ kdf: KDF, verifier: await deriveMasterLockVerifier("first", KDF) });
|
|
252
268
|
expect(first.ok).toBe(true);
|
|
253
269
|
const second = await b.enroll({ kdf: KDF, verifier: await deriveMasterLockVerifier("second", KDF) });
|
|
254
270
|
expect(second).toEqual({ ok: false, reason: "already-configured" });
|
|
255
271
|
});
|
|
256
272
|
|
|
257
|
-
test("🔴
|
|
273
|
+
test("🔴 an app-wide lock with no account CAN now be armed by a POST — and that is the point", async () => {
|
|
274
|
+
// The inverse of what this asserted before the accounts model. Enrollment used to be
|
|
275
|
+
// refused outside per-person mode so that mounting the wall could not silently arm a
|
|
276
|
+
// lock the owner had not chosen; the owner has since ruled that an app with no master
|
|
277
|
+
// password must take one before it serves anything, which makes this route the ONLY
|
|
278
|
+
// way out of that state. It is not a hole for the reason the engine's note gives at
|
|
279
|
+
// length: whoever reaches it already holds a live session for an account the app
|
|
280
|
+
// admits, and it refuses the moment an account exists.
|
|
258
281
|
const appWide = new MasterLock({ store: createMemoryMasterLockStore(null) });
|
|
259
|
-
const
|
|
282
|
+
const first = await appWide.enroll({
|
|
260
283
|
kdf: KDF,
|
|
261
284
|
verifier: await deriveMasterLockVerifier("anything", KDF),
|
|
262
285
|
});
|
|
263
|
-
expect(
|
|
286
|
+
expect(first.ok && first.accountId).toBe("acct-1");
|
|
287
|
+
|
|
288
|
+
// And it can only ever CREATE the password that was never set.
|
|
289
|
+
const again = await appWide.enroll({
|
|
290
|
+
kdf: KDF,
|
|
291
|
+
verifier: await deriveMasterLockVerifier("something else", KDF),
|
|
292
|
+
});
|
|
293
|
+
expect(again).toEqual({ ok: false, reason: "already-configured" });
|
|
264
294
|
});
|
|
265
295
|
|
|
266
296
|
test("a malformed submission is refused rather than stored", async () => {
|
|
@@ -373,6 +403,6 @@ describe("claim 4 — nothing a caller sends chooses whose lock they meet", () =
|
|
|
373
403
|
expect(key).toBe(masterLockPrincipalKey(OWNER));
|
|
374
404
|
expect(value).not.toContain(OWNER);
|
|
375
405
|
expect(value).not.toContain("the owner's own password");
|
|
376
|
-
expect(JSON.parse(value).verifierHash.startsWith("$argon2")).toBe(true);
|
|
406
|
+
expect(JSON.parse(value).accounts[0].verifierHash.startsWith("$argon2")).toBe(true);
|
|
377
407
|
});
|
|
378
408
|
});
|
|
@@ -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, type MasterLockStore, readRecord } from "./masterLock";
|
|
@@ -105,7 +111,6 @@ export class MasterLockDirectory {
|
|
|
105
111
|
};
|
|
106
112
|
const lock = new MasterLock({
|
|
107
113
|
store,
|
|
108
|
-
enrollable: true,
|
|
109
114
|
...(this.options.now ? { now: this.options.now } : {}),
|
|
110
115
|
...(this.options.log
|
|
111
116
|
? { log: (line: string) => this.options.log?.(`[${principalId}] ${line}`) }
|
|
@@ -34,7 +34,12 @@ describe("mintMasterLockSeed", () => {
|
|
|
34
34
|
const a = await mintMasterLockSeed("stage-password-1");
|
|
35
35
|
const b = await mintMasterLockSeed("stage-password-1");
|
|
36
36
|
expect(a.kdf.salt).not.toBe(b.kdf.salt);
|
|
37
|
-
expect(a.verifierHash).not.toBe(b.verifierHash);
|
|
37
|
+
expect(a.accounts[0]?.verifierHash).not.toBe(b.accounts[0]?.verifierHash);
|
|
38
|
+
// 🔴 Both mints name the SAME account id, and that is deliberate rather than an
|
|
39
|
+
// oversight: a seed is what an app boots with before anyone has typed anything, so
|
|
40
|
+
// the tenant it names has to be the tenant whose rows the app already holds.
|
|
41
|
+
expect(a.accounts[0]?.id).toBe("acct-1");
|
|
42
|
+
expect(b.accounts[0]?.id).toBe("acct-1");
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
it("refuses a password short enough to make the lock decorative", async () => {
|
|
@@ -50,7 +55,7 @@ describe("serializeMasterLockSeed", () => {
|
|
|
50
55
|
const env = serializeMasterLockSeed(record);
|
|
51
56
|
expect(env.startsWith("'")).toBe(true);
|
|
52
57
|
expect(env.endsWith("'")).toBe(true);
|
|
53
|
-
expect(readRecord(env)?.verifierHash).toBe(record.verifierHash);
|
|
58
|
+
expect(readRecord(env)?.accounts[0]?.verifierHash).toBe(record.accounts[0]?.verifierHash);
|
|
54
59
|
});
|
|
55
60
|
});
|
|
56
61
|
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
deriveMasterLockVerifier,
|
|
29
29
|
newMasterLockKdfParams,
|
|
30
30
|
} from "cursedbelt-core/master-lock/kdf";
|
|
31
|
-
import type
|
|
31
|
+
import { FIRST_ACCOUNT_ID, type MasterLockRecord } from "./masterLock";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* The idle window a freshly minted lock starts with.
|
|
@@ -48,6 +48,10 @@ export interface MintMasterLockSeedOptions {
|
|
|
48
48
|
idleMs?: number;
|
|
49
49
|
/** Reuse existing params instead of generating a salt — for a rotation. */
|
|
50
50
|
kdf?: MasterLockKdfParams;
|
|
51
|
+
/** What to call the tenant this seed creates. Defaults to `Account 1`. */
|
|
52
|
+
label?: string;
|
|
53
|
+
/** The owner's reminder for this password, shown on the lock page's "I forgot". */
|
|
54
|
+
hint?: string;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -69,8 +73,24 @@ export async function mintMasterLockSeed(
|
|
|
69
73
|
const verifier = await deriveMasterLockVerifier(password, kdf);
|
|
70
74
|
return {
|
|
71
75
|
kdf,
|
|
72
|
-
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
73
76
|
idleMs: options.idleMs ?? DEFAULT_SEED_IDLE_MS,
|
|
77
|
+
/*
|
|
78
|
+
* 🔴 ONE account, and its id is `FIRST_ACCOUNT_ID` rather than anything derived
|
|
79
|
+
* from `options`. A seed is what an app boots with before anybody has typed
|
|
80
|
+
* anything, so the tenant it names is the tenant whose rows the app already holds —
|
|
81
|
+
* and `readRecord` maps the legacy single-password shape onto the same id for
|
|
82
|
+
* exactly that reason. Two different "first" ids would give a seeded app and a
|
|
83
|
+
* migrated app different answers to "whose library is this".
|
|
84
|
+
*/
|
|
85
|
+
accounts: [
|
|
86
|
+
{
|
|
87
|
+
id: FIRST_ACCOUNT_ID,
|
|
88
|
+
label: options.label ?? "Account 1",
|
|
89
|
+
hint: options.hint ?? "",
|
|
90
|
+
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
91
|
+
createdAt: 0,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
74
94
|
};
|
|
75
95
|
}
|
|
76
96
|
|