cursedbelt-server 3.0.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.
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`docs/retention.md` in `cursed-satellites`) by
13
+ * into the fleet standard (`cursedbelt-server/retention`, in this package) by
14
14
  * declaring the table, which puts the number under the owner's control in the
15
15
  * station rather than in a constant here:
16
16
  *
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`docs/retention.md` in `cursed-satellites`) by
13
+ * into the fleet standard (`cursedbelt-server/retention`, in this package) by
14
14
  * declaring the table, which puts the number under the owner's control in the
15
15
  * station rather than in a constant here:
16
16
  *
@@ -168,6 +168,24 @@ export function createMasterLockGuard(options) {
168
168
  }
169
169
  if (path === MASTER_LOCK_PATHS.change) {
170
170
  const body = await readJson(req);
171
+ /*
172
+ * 🔴 An OLDER client sends a freshly-minted `kdf` with a rotation, because that is
173
+ * what the wire looked like before accounts existed. Ignoring it silently would be
174
+ * a lockout, not a compatibility shim: the verifier in the same body was derived
175
+ * under THAT salt, so storing it would leave a hash nothing can ever match — the
176
+ * owner would set a new password, be logged out by the rotation, and find that
177
+ * neither the old nor the new one opens the app.
178
+ *
179
+ * There is no safe way to honour it either, since every account on this app shares
180
+ * one descriptor. So it is refused, loudly, with the one thing that distinguishes
181
+ * it from a wrong password.
182
+ */
183
+ if (body?.kdf !== undefined) {
184
+ const supplied = body.kdf;
185
+ if (!lock.kdf || supplied?.salt !== lock.kdf.salt) {
186
+ return Response.json({ error: "stale-client" }, { status: 409, headers: noStore });
187
+ }
188
+ }
171
189
  const result = await lock.change(req, {
172
190
  currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
173
191
  verifier: typeof body?.verifier === "string" ? body.verifier : "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
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.",
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`docs/retention.md` in `cursed-satellites`) by
13
+ * into the fleet standard (`cursedbelt-server/retention`, in this package) by
14
14
  * declaring the table, which puts the number under the owner's control in the
15
15
  * station rather than in a constant here:
16
16
  *
@@ -157,6 +157,31 @@ describe("while locked", () => {
157
157
  expect(JSON.stringify(body)).not.toContain("argon2");
158
158
  });
159
159
 
160
+ test("🔴 a rotation carrying a STALE kdf is refused, not silently honoured", async () => {
161
+ // The pre-accounts wire minted a fresh salt on every rotation. Every account now
162
+ // derives under one shared descriptor, so honouring that would store a verifier hash
163
+ // nothing can ever match — the owner sets a new password, the rotation logs him out,
164
+ // and neither the old nor the new one opens the app. `409 stale-client` is the one
165
+ // refusal here that is deliberately distinguishable from a wrong password.
166
+ const { guard } = build();
167
+ const cookie = await open(guard);
168
+ const stale = await guard.handle(
169
+ post(
170
+ MASTER_LOCK_PATHS.change,
171
+ {
172
+ currentVerifier: VERIFIER,
173
+ kdf: { v: 1, alg: "PBKDF2-SHA256", iter: 600_000, salt: "ZZZZZZZZZZZZZZZZZZZZZZ" },
174
+ verifier: "whatever-that-salt-produced",
175
+ },
176
+ cookie,
177
+ ),
178
+ );
179
+ expect(stale?.status).toBe(409);
180
+ expect(await stale?.json()).toEqual({ error: "stale-client" });
181
+ // And the password that already worked still does.
182
+ expect((await guard.handle(post(MASTER_LOCK_PATHS.unlock, { verifier: VERIFIER })))?.status).toBe(200);
183
+ });
184
+
160
185
  test("🔴 status never says how many accounts this app has", async () => {
161
186
  // The lock screen must read identically whether the owner keeps one tenant or ten:
162
187
  // the number of master passwords is itself a fact worth hiding, and `status` is
@@ -259,6 +259,27 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
259
259
  }
260
260
  if (path === MASTER_LOCK_PATHS.change) {
261
261
  const body = await readJson(req);
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
+ }
262
283
  const result = await lock.change(req, {
263
284
  currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
264
285
  verifier: typeof body?.verifier === "string" ? body.verifier : "",