@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 +2 -2
- package/src/crypto/__tests__/pii-field-encryption.test.ts +28 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
- package/src/db/event-store-executor-write.ts +23 -2
- package/src/db/event-store-executor.ts +1 -1
- package/src/engine/entity-handlers.ts +9 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.156.
|
|
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.
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|