@cosmicdrift/kumiko-framework 0.156.0 → 0.156.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.156.0",
3
+ "version": "0.156.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.156.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.156.1",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -1,13 +1,20 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine/factories";
3
3
  import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
4
- import { KeyNotFoundError, type KmsContext, subjectIdFromKey } from "../kms-adapter";
4
+ import {
5
+ KeyErasedError,
6
+ KeyNotFoundError,
7
+ type KmsContext,
8
+ subjectIdFromKey,
9
+ subjectIdToKey,
10
+ } from "../kms-adapter";
5
11
  import {
6
12
  decryptPiiFieldValues,
7
13
  decryptPiiValueForSubject,
8
14
  encryptPiiFieldValues,
9
15
  encryptPiiValueForSubject,
10
16
  isPiiCiphertext,
17
+ PII_CIPHERTEXT_PREFIX,
11
18
  PII_ERASED_SENTINEL,
12
19
  } from "../pii-field-encryption";
13
20
  import { collectPiiSubjectFields } from "../subject-resolver";
@@ -291,3 +298,23 @@ describe("encryptPiiValueForSubject / decryptPiiValueForSubject (kumiko-platform
291
298
  expect(read).toBe("plain-value");
292
299
  });
293
300
  });
301
+
302
+ describe("cross-subject decrypt leak (kumiko-framework#1190)", () => {
303
+ test("ciphertext forged to a different-but-valid subject's DEK fails GCM auth, not just KeyNotFound", async () => {
304
+ const kms = new InMemoryKmsAdapter();
305
+ const subjectA = { kind: "tenant" as const, tenantId: UUID_A };
306
+ const subjectB = { kind: "tenant" as const, tenantId: UUID_B };
307
+ // Both subjects must have a real key — otherwise this only re-proves the
308
+ // existing "ciphertext without a key row fails loud" (KeyNotFoundError) case.
309
+ await kms.createKey(subjectA);
310
+
311
+ const storedForB = await encryptPiiValueForSubject(kms, subjectB, "tenant-b-secret", KMS_CTX);
312
+ const blob = storedForB.slice(storedForB.lastIndexOf(":") + 1);
313
+ const forgedForA = `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subjectA)}:${blob}`;
314
+
315
+ const attempt = decryptPiiValueForSubject(kms, forgedForA, KMS_CTX);
316
+ await expect(attempt).rejects.not.toBeInstanceOf(KeyNotFoundError);
317
+ await expect(attempt).rejects.not.toBeInstanceOf(KeyErasedError);
318
+ await expect(attempt).rejects.toThrow();
319
+ });
320
+ });
@@ -419,6 +419,81 @@ describe("event-store-executor — encrypted fields", () => {
419
419
  expect(updated.data.data["secretNote"]).toBe("new-note");
420
420
  });
421
421
 
422
+ test("update WITHOUT skipUnchanged still re-encrypts a resubmitted-but-unchanged encrypted field (KEK-rotation/backfill contract)", async () => {
423
+ // Regression guard: direct executor.update() callers that don't opt into
424
+ // skipUnchanged rely on today's always-re-encrypt behavior to force a
425
+ // fresh ciphertext for an unchanged plaintext — e.g. auth-mfa's
426
+ // KEK-rotation job (reencrypt.job.ts) and the user-data-rights #494
427
+ // backfill both resubmit the CURRENT value on purpose to land a real
428
+ // event. A blanket value-diff here would silently break both.
429
+ const created = await crud.create(
430
+ { email: "force-enc@test.de", secretNote: "same-note" },
431
+ adminUser,
432
+ tdb,
433
+ );
434
+ if (!created.isSuccess) throw new Error("create failed");
435
+
436
+ const before = (await asRawClient(testDb.db).unsafe(
437
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
438
+ )) as Array<{ secret_note: string }>;
439
+
440
+ const updated = await crud.update(
441
+ { id: created.data.id, version: 1, changes: { secretNote: "same-note" } },
442
+ adminUser,
443
+ tdb,
444
+ );
445
+ if (!updated.isSuccess) throw new Error("update failed");
446
+
447
+ const after = (await asRawClient(testDb.db).unsafe(
448
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
449
+ )) as Array<{ secret_note: string }>;
450
+
451
+ // Fresh AEAD nonce per encrypt call → different ciphertext even though
452
+ // the plaintext is identical. Byte-identical would mean the write was
453
+ // skipped, which is exactly what would break KEK rotation.
454
+ expect(after[0]?.secret_note).toBeDefined();
455
+ expect(after[0]?.secret_note).not.toBe(before[0]?.secret_note);
456
+ });
457
+
458
+ test("update with skipUnchanged: true omits a resubmitted-but-unchanged encrypted field — no phantom re-encrypt (#464)", async () => {
459
+ const created = await crud.create(
460
+ { email: "noop-enc@test.de", secretNote: "same-note" },
461
+ adminUser,
462
+ tdb,
463
+ );
464
+ if (!created.isSuccess) throw new Error("create failed");
465
+
466
+ const before = (await asRawClient(testDb.db).unsafe(
467
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc@test.de' LIMIT 1`,
468
+ )) as Array<{ secret_note: string }>;
469
+
470
+ const updated = await crud.update(
471
+ {
472
+ id: created.data.id,
473
+ version: 1,
474
+ changes: { secretNote: "same-note", email: "noop-enc-2@test.de" },
475
+ },
476
+ adminUser,
477
+ tdb,
478
+ { skipUnchanged: true },
479
+ );
480
+ if (!updated.isSuccess) throw new Error("update failed");
481
+
482
+ const after = (await asRawClient(testDb.db).unsafe(
483
+ `SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc-2@test.de' LIMIT 1`,
484
+ )) as Array<{ secret_note: string }>;
485
+
486
+ // Re-encrypting the same plaintext produces a fresh AEAD nonce → a
487
+ // different ciphertext even for an unchanged value. Byte-identical
488
+ // ciphertext proves the column was never touched by this update.
489
+ expect(after[0]?.secret_note).toBe(before[0]?.secret_note);
490
+
491
+ const rows = (await asRawClient(testDb.db).unsafe(
492
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.updated' ORDER BY id DESC LIMIT 1`,
493
+ )) as Array<{ payload: { changes?: Record<string, unknown> } }>;
494
+ expect(rows[0]?.payload.changes).not.toHaveProperty("secretNote");
495
+ });
496
+
422
497
  test("update's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
423
498
  // Regression: `previous` in the STORED event came from loadById(), which
424
499
  // decrypts — appending it unchanged would put the plaintext of an
@@ -32,6 +32,20 @@ import { selectMany } from "./query";
32
32
  // unchanged from the original, just relocated behind an explicit
33
33
  // ExecutorContext instead of capturing the factory's local scope directly.
34
34
 
35
+ // updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
36
+ // key is dropped before encryption so pii/encrypted fields don't get a fresh
37
+ // AEAD ciphertext (new nonce) for no real change, and the event's `changes`
38
+ // don't carry a phantom diff. Opt-in, not the executor's default, because
39
+ // direct executor.update() callers rely on the current behavior to FORCE a
40
+ // re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
41
+ // and the user-data-rights #494 backfill both resubmit the current value on
42
+ // purpose to land a fresh event/ciphertext. Only the generic entity update
43
+ // handler (entity-handlers.ts) sets this flag; those two call their own
44
+ // executor directly and never see it.
45
+ function isUnchangedValue(a: unknown, b: unknown): boolean {
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+
35
49
  export function createWriteVerbs(
36
50
  ctx: ExecutorContext,
37
51
  ): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
@@ -282,9 +296,16 @@ export function createWriteVerbs(
282
296
  // Compound-Types Auto-Convert (alle in einem Pass).
283
297
  // subjectSource: partial changes may carry a pii field without its
284
298
  // ownerField — the merged row still names the subject.
285
- const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
299
+ const submittedChanges = updateOptions?.skipUnchanged
300
+ ? Object.fromEntries(
301
+ Object.entries(payload.changes).filter(
302
+ ([key, value]) => !isUnchangedValue(value, previous[key]),
303
+ ),
304
+ )
305
+ : payload.changes;
306
+ const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
286
307
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
287
- onlyKeys: Object.keys(payload.changes),
308
+ onlyKeys: Object.keys(submittedChanges),
288
309
  subjectSource: mergedNew,
289
310
  });
290
311
 
@@ -53,7 +53,7 @@ export type EventStoreExecutor = {
53
53
  payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
54
54
  user: SessionUser,
55
55
  db: import("./tenant-db").TenantDb,
56
- options?: { skipOptimisticLock?: boolean },
56
+ options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
57
57
  ) => Promise<WriteResult<SaveContext>>;
58
58
 
59
59
  delete: (
@@ -176,7 +176,15 @@ export function defineEntityWriteHandler(
176
176
  changes: buildUpdateSchema(entity),
177
177
  });
178
178
  handler = async (event, ctx) =>
179
- executor.update(event.payload as UpdatePayload, event.user, ctx.db); // @cast-boundary engine-payload
179
+ // skipUnchanged (#464): API-driven updates diff against the stored
180
+ // row so a resubmitted-but-identical field doesn't force a fresh
181
+ // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
182
+ // KEK-rotation, the user-data-rights #494 backfill) don't go through
183
+ // this handler and keep today's always-re-encrypt behavior, which
184
+ // they rely on to intentionally force a fresh event/ciphertext.
185
+ executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
186
+ skipUnchanged: true,
187
+ }); // @cast-boundary engine-payload
180
188
  break;
181
189
  case "delete":
182
190
  schema = idSchema;
@@ -100,6 +100,35 @@ describe("dispatcher.write", () => {
100
100
  expect(result.isSuccess).toBe(true);
101
101
  });
102
102
 
103
+ test("user-bucketed handler without RateLimitResolver → internal_error", async () => {
104
+ // Misconfig: a handler opts into L3 rate-limit but the app never loaded the
105
+ // rate-limiting feature. User buckets cannot skip (unlike ip+no-IP), so we
106
+ // fail loud at first write instead of silently running uncapped.
107
+ const rlFeature = defineFeature("rl-user", (r) => {
108
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
109
+ r.writeHandler(
110
+ "item:create",
111
+ z.object({ name: z.string() }),
112
+ async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
113
+ {
114
+ access: { openToAll: true },
115
+ rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
116
+ },
117
+ );
118
+ });
119
+ const dispatcher = createDispatcher(createRegistry([rlFeature]), {});
120
+ const result = await dispatcher.write(
121
+ "rl-user:write:item:create",
122
+ { name: "should-not-run" },
123
+ createTestUser(),
124
+ );
125
+ expect(result.isSuccess).toBe(false);
126
+ if (!result.isSuccess) {
127
+ expect(result.error.code).toBe("internal_error");
128
+ expect(result.error.message).toMatch(/RateLimitResolver is configured/);
129
+ }
130
+ });
131
+
103
132
  test("ctx.user ist Convenience-Alias auf event.user (gleicher Wert)", async () => {
104
133
  // Pinst dass der Handler auf ctx.user zugreifen kann ohne den
105
134
  // typo-resistenten event.user-Pfad zu nutzen. Identitätsprüfung