cursedbelt-server 3.0.1 โ†’ 4.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.
Files changed (77) hide show
  1. package/dist/server/auth/jwt.d.ts +1 -1
  2. package/dist/server/auth/jwt.js +2 -2
  3. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  4. package/dist/server/master-lock/accountsPage.js +455 -0
  5. package/dist/server/master-lock/guard.js +39 -1
  6. package/dist/server/master-lock/index.d.ts +10 -2
  7. package/dist/server/master-lock/index.js +10 -2
  8. package/dist/server/master-lock/lockPage.d.ts +9 -0
  9. package/dist/server/master-lock/lockPage.js +44 -36
  10. package/dist/server/media-bun/streaming.d.ts +0 -1
  11. package/dist/server/media-bun/streaming.js +1 -1
  12. package/dist/server/storage/files/catalogue.d.ts +2 -2
  13. package/dist/server/storage/files/catalogue.js +18 -208
  14. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  15. package/dist/server/storage/files/fileRoutes.js +1 -74
  16. package/dist/server/storage/files/index.d.ts +1 -1
  17. package/dist/server/storage/files/index.js +1 -1
  18. package/dist/server/storage/files/migrations.js +9 -6
  19. package/dist/server/storage/files/stack.d.ts +6 -31
  20. package/dist/server/storage/files/stack.js +0 -27
  21. package/dist/server/storage/files/types.d.ts +10 -60
  22. package/dist/server/storage/files/types.js +9 -0
  23. package/dist/server/storage/index.d.ts +2 -2
  24. package/dist/server/storage/index.js +1 -1
  25. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  26. package/dist/server/storage/types.d.ts +0 -9
  27. package/dist/server/storage/types.js +2 -9
  28. package/package.json +2 -2
  29. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  30. package/src/server/auth/jwt.ts +2 -2
  31. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  32. package/src/server/master-lock/accountsPage.ts +465 -0
  33. package/src/server/master-lock/guard.ts +44 -1
  34. package/src/server/master-lock/index.ts +22 -2
  35. package/src/server/master-lock/lockPage.spec.ts +20 -16
  36. package/src/server/master-lock/lockPage.ts +45 -36
  37. package/src/server/media-bun/streaming.ts +0 -6
  38. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  39. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  40. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  41. package/src/server/storage/files/adapter.spec.ts +0 -1
  42. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  43. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  44. package/src/server/storage/files/catalogue.ts +19 -240
  45. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  46. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  47. package/src/server/storage/files/fileRoutes.ts +2 -96
  48. package/src/server/storage/files/index.ts +1 -4
  49. package/src/server/storage/files/migrations.ts +9 -7
  50. package/src/server/storage/files/stack.spec.ts +11 -111
  51. package/src/server/storage/files/stack.ts +9 -61
  52. package/src/server/storage/files/types.ts +20 -68
  53. package/src/server/storage/index.ts +0 -4
  54. package/src/server/storage/mountMediaRoutes.ts +1 -7
  55. package/src/server/storage/types.ts +2 -11
  56. package/src/shippedFilesAreTracked.spec.ts +3 -2
  57. package/dist/server/private-media/crypto.d.ts +0 -37
  58. package/dist/server/private-media/crypto.js +0 -111
  59. package/dist/server/private-media/index-store.d.ts +0 -31
  60. package/dist/server/private-media/index-store.js +0 -36
  61. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  62. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  63. package/dist/server/private-media/pipeline.d.ts +0 -37
  64. package/dist/server/private-media/pipeline.js +0 -97
  65. package/dist/server/private-media/unlock.d.ts +0 -52
  66. package/dist/server/private-media/unlock.js +0 -55
  67. package/src/server/private-media/blobRoute.spec.ts +0 -165
  68. package/src/server/private-media/crypto.spec.ts +0 -81
  69. package/src/server/private-media/crypto.ts +0 -155
  70. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  71. package/src/server/private-media/index-store.ts +0 -83
  72. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  73. package/src/server/private-media/pipeline.spec.ts +0 -111
  74. package/src/server/private-media/pipeline.ts +0 -170
  75. package/src/server/private-media/routes.spec.ts +0 -265
  76. package/src/server/private-media/unlock.ts +0 -112
  77. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -0,0 +1,231 @@
1
+ /**
2
+ * ๐Ÿ”ด The accounts page, and the one bug it exists to make impossible.
3
+ *
4
+ * `cursedbelt@2.5.1`'s React dialog minted a FRESH `kdf` salt on every rotation. Every
5
+ * account on an app derives under one shared descriptor, so honouring that salt stores a
6
+ * verifier hash nothing can match โ€” the owner sets a new password, the rotation logs him
7
+ * out, and neither the old nor the new one opens the app. The server refuses that shape with
8
+ * `409 stale-client`; this page is what replaces the client that sent it.
9
+ *
10
+ * So the load-bearing assertion here is a NEGATIVE one: the rotation script this server
11
+ * serves must never put a `kdf` on the wire, and must never be able to mint one. A comment
12
+ * saying so would not have caught the dialog that did, which is why it is a test.
13
+ */
14
+ import { beforeAll, describe, expect, test } from "bun:test";
15
+ import {
16
+ MASTER_LOCK_PATHS,
17
+ type MasterLockKdfParams,
18
+ deriveMasterLockVerifier,
19
+ } from "cursedbelt-core/master-lock";
20
+ import {
21
+ MASTER_LOCK_ACCOUNTS_PAGE_PATHS,
22
+ MASTER_LOCK_ACCOUNTS_SCRIPT,
23
+ MASTER_LOCK_ACCOUNTS_STYLE,
24
+ accountsPageHtml,
25
+ } from "./accountsPage";
26
+ import { createMasterLockGuard } from "./guard";
27
+ import { MasterLock } from "./masterLock";
28
+ import { createMemoryMasterLockStore } from "./store";
29
+
30
+ const KDF: MasterLockKdfParams = {
31
+ v: 1,
32
+ alg: "PBKDF2-SHA256",
33
+ iter: 1,
34
+ salt: "AAECAwQFBgcICQoLDA0ODw",
35
+ };
36
+ let VERIFIER = "";
37
+ let NEXT_VERIFIER = "";
38
+ let SEED = "";
39
+
40
+ beforeAll(async () => {
41
+ VERIFIER = await deriveMasterLockVerifier("open sesame", KDF);
42
+ NEXT_VERIFIER = await deriveMasterLockVerifier("the one I picked myself", KDF);
43
+ SEED = JSON.stringify({
44
+ kdf: KDF,
45
+ verifierHash: await Bun.password.hash(VERIFIER, { algorithm: "argon2id" }),
46
+ });
47
+ });
48
+
49
+ function build() {
50
+ const lock = new MasterLock({ store: createMemoryMasterLockStore(), seedJson: SEED });
51
+ return { lock, guard: createMasterLockGuard({ lock, appLabel: "Test App" }) };
52
+ }
53
+
54
+ const doc = (path: string, cookie?: string): Request =>
55
+ new Request(`http://app.test${path}`, {
56
+ headers: { "sec-fetch-dest": "document", ...(cookie ? { cookie } : {}) },
57
+ });
58
+ const xhr = (path: string, cookie?: string): Request =>
59
+ new Request(`http://app.test${path}`, {
60
+ headers: { "sec-fetch-dest": "empty", ...(cookie ? { cookie } : {}) },
61
+ });
62
+ const send = (method: string, path: string, body: unknown, cookie?: string): Request =>
63
+ new Request(`http://app.test${path}`, {
64
+ method,
65
+ headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) },
66
+ body: JSON.stringify(body),
67
+ });
68
+
69
+ /** Unlock and return the cookie pair the browser would send back. */
70
+ async function open(guard: ReturnType<typeof build>["guard"]): Promise<string> {
71
+ const res = await guard.handle(send("POST", MASTER_LOCK_PATHS.unlock, { verifier: VERIFIER }));
72
+ expect(res?.status).toBe(200);
73
+ return (res?.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
74
+ }
75
+
76
+ describe("๐Ÿ”ด the rotation this page performs cannot be the one that locked the owner out", () => {
77
+ test("the served script never sends a kdf, and cannot mint one", () => {
78
+ // Two independent readings of the same property, because either alone is beatable:
79
+ // a script that never writes the word could still forward one it was handed, and a
80
+ // script with no randomness could still echo the server's.
81
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).not.toContain("kdf:");
82
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).not.toContain("getRandomValues");
83
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).not.toContain("newKdf");
84
+ // It DOES read the app's params โ€” that is how it derives under the shared descriptor.
85
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).toContain("kdf = body.kdf");
86
+ });
87
+
88
+ test("a rotation posted the way this page posts it is ACCEPTED end to end", async () => {
89
+ const { guard, lock } = build();
90
+ const cookie = await open(guard);
91
+ const res = await guard.handle(
92
+ send(
93
+ "POST",
94
+ MASTER_LOCK_PATHS.change,
95
+ { currentVerifier: VERIFIER, verifier: NEXT_VERIFIER },
96
+ cookie,
97
+ ),
98
+ );
99
+ expect(res?.status).toBe(200);
100
+ // The new password opens the SAME tenant, which is the owner's whole requirement.
101
+ const reopened = await lock.unlock(NEXT_VERIFIER);
102
+ expect(reopened.ok && reopened.accountId).toBe("acct-1");
103
+ });
104
+
105
+ test("and the shape it replaced is still refused, distinguishably", async () => {
106
+ // The 409 stays. This page is why nothing sends that shape any more โ€” not a reason to
107
+ // start honouring it, which would be the lockout itself.
108
+ const { guard } = build();
109
+ const cookie = await open(guard);
110
+ const res = await guard.handle(
111
+ send(
112
+ "POST",
113
+ MASTER_LOCK_PATHS.change,
114
+ {
115
+ kdf: { v: 1, alg: "PBKDF2-SHA256", iter: 600_000, salt: "ZZZZZZZZZZZZZZZZZZZZZZ" },
116
+ currentVerifier: VERIFIER,
117
+ verifier: NEXT_VERIFIER,
118
+ },
119
+ cookie,
120
+ ),
121
+ );
122
+ expect(res?.status).toBe(409);
123
+ expect(await res?.json()).toEqual({ error: "stale-client" });
124
+ });
125
+ });
126
+
127
+ describe("who the page is served to", () => {
128
+ test("๐Ÿ”ด a document request while LOCKED gets the lock page, never the accounts page", async () => {
129
+ // It names every tenant, so it is behind the wall like the JSON is. Answering with the
130
+ // lock page rather than a 401 is what makes unlocking land back here.
131
+ const { guard } = build();
132
+ const res = await guard.handle(doc(MASTER_LOCK_PATHS.accounts));
133
+ expect(res?.status).toBe(200);
134
+ const html = (await res?.text()) ?? "";
135
+ expect(html).toContain("<title>Locked ยท Test App</title>");
136
+ expect(html).not.toContain("Master passwords");
137
+ });
138
+
139
+ test("the JSON half while locked is still the one uniform 401", async () => {
140
+ const { guard } = build();
141
+ const res = await guard.handle(xhr(MASTER_LOCK_PATHS.accounts));
142
+ expect(res?.status).toBe(401);
143
+ expect(await res?.json()).toEqual({ error: "locked", retryAfterMs: 0 });
144
+ });
145
+
146
+ test("one route, two representations โ€” the page for a browser, the list for its fetch", async () => {
147
+ const { guard } = build();
148
+ const cookie = await open(guard);
149
+
150
+ const page = await guard.handle(doc(MASTER_LOCK_PATHS.accounts, cookie));
151
+ expect(page?.headers.get("content-type")).toContain("text/html");
152
+ expect(await page?.text()).toContain("<title>Master passwords ยท Test App</title>");
153
+
154
+ const json = await guard.handle(xhr(MASTER_LOCK_PATHS.accounts, cookie));
155
+ expect(json?.headers.get("content-type")).toContain("application/json");
156
+ expect(await json?.json()).toEqual({
157
+ accounts: [{ id: "acct-1", label: "Account 1", hint: "", createdAt: 0, current: true }],
158
+ });
159
+ });
160
+
161
+ test("๐Ÿ”ด the page itself carries no account data โ€” the shell is the same for everyone", async () => {
162
+ // Whoever is looking has already unlocked, but the page is cached by nothing and
163
+ // rendered from nothing: a label or a hint baked into the HTML would be a second copy
164
+ // of the one fact the JSON route is the gate for.
165
+ const { guard, lock } = build();
166
+ const cookie = await open(guard);
167
+ const req = new Request(`http://app.test${MASTER_LOCK_PATHS.accounts}`, {
168
+ headers: { cookie, "sec-fetch-dest": "document" },
169
+ });
170
+ expect(
171
+ await lock.addAccount(req, {
172
+ currentVerifier: VERIFIER,
173
+ verifier: NEXT_VERIFIER,
174
+ label: "A second tenant",
175
+ hint: "a hint nobody else may read",
176
+ }),
177
+ ).toMatchObject({ ok: true });
178
+
179
+ const html = (await (await guard.handle(doc(MASTER_LOCK_PATHS.accounts, cookie)))?.text()) ?? "";
180
+ expect(html).not.toContain("A second tenant");
181
+ expect(html).not.toContain("a hint nobody else may read");
182
+ expect(html).not.toContain("acct-");
183
+ });
184
+ });
185
+
186
+ describe("the page's own shape", () => {
187
+ test("no inline script, nothing loaded off-origin, and the app label escaped", () => {
188
+ const html = accountsPageHtml({ appLabel: '<img src=x onerror="alert(1)">' });
189
+ expect(html).not.toContain("<img");
190
+ expect(html).toContain("&lt;img");
191
+ // The guard serves it under the same CSP as the lock page โ€” `script-src 'self'` with
192
+ // no nonce plumbing โ€” so an inline <script> would be a blank page in production and a
193
+ // green test here.
194
+ expect(html).not.toMatch(/<script(?![^>]*\bsrc=)/);
195
+ for (const [, value] of html.matchAll(/\b(?:src|href)="([^"]*)"/g)) {
196
+ expect(value.startsWith("/") || value.startsWith("data:")).toBe(true);
197
+ }
198
+ });
199
+
200
+ test("it links the LOCK's stylesheet as well as its own, so the palette cannot split", () => {
201
+ const html = accountsPageHtml({ appLabel: "Collections" });
202
+ expect(html).toContain(`href="${MASTER_LOCK_PATHS.style}"`);
203
+ expect(html).toContain(`href="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style}"`);
204
+ // Which is only true while this sheet defines no tokens of its own.
205
+ expect(MASTER_LOCK_ACCOUNTS_STYLE).not.toContain(":root");
206
+ expect(MASTER_LOCK_ACCOUNTS_STYLE).not.toContain("url(");
207
+ });
208
+
209
+ test("its two assets are served, and reachable without an unlock like the lock's are", async () => {
210
+ // They carry no tenant data โ€” every app that mounts this guard serves the same bytes โ€”
211
+ // and gating them would mean the page going blank the moment the idle clock lapsed
212
+ // between the document and its stylesheet.
213
+ const { guard } = build();
214
+ const style = await guard.handle(new Request(`http://app.test${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style}`));
215
+ expect(style?.status).toBe(200);
216
+ expect(style?.headers.get("content-type")).toContain("text/css");
217
+
218
+ const script = await guard.handle(new Request(`http://app.test${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}`));
219
+ expect(script?.status).toBe(200);
220
+ expect(script?.headers.get("content-type")).toContain("text/javascript");
221
+ expect(await script?.text()).toBe(MASTER_LOCK_ACCOUNTS_SCRIPT);
222
+ });
223
+
224
+ test("every path the script talks to is one the guard actually answers", () => {
225
+ // The script names them off `MASTER_LOCK_PATHS`, interpolated at build time, so this
226
+ // pins the three it uses against the wire rather than against a hand-typed string.
227
+ for (const path of [MASTER_LOCK_PATHS.status, MASTER_LOCK_PATHS.accounts, MASTER_LOCK_PATHS.change]) {
228
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).toContain(JSON.stringify(path));
229
+ }
230
+ });
231
+ });