@cosmicdrift/kumiko-framework 0.118.0 → 0.119.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.118.0",
3
+ "version": "0.119.0",
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>",
@@ -185,7 +185,7 @@
185
185
  "zod": "^4.4.3"
186
186
  },
187
187
  "devDependencies": {
188
- "@cosmicdrift/kumiko-dispatcher-live": "0.118.0",
188
+ "@cosmicdrift/kumiko-dispatcher-live": "0.119.0",
189
189
  "bun-types": "^1.3.13",
190
190
  "pino-pretty": "^13.1.3"
191
191
  },
@@ -0,0 +1,103 @@
1
+ // Response-Tripwire (#820): ein kumiko-pii:-Ciphertext in einer JSON-Response
2
+ // ist immer ein Bug (raw-Read am Decrypt vorbei). Dev/Test → 500 (Test wird
3
+ // rot), Prod → redact + Error-Log, ohne KMS → kein Scan (pass-through).
4
+
5
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import {
8
+ configurePiiSubjectKms,
9
+ InMemoryKmsAdapter,
10
+ resetPiiSubjectKmsForTests,
11
+ } from "../../crypto";
12
+ import { defineFeature } from "../../engine/define-feature";
13
+ import { defineQueryHandler } from "../../engine/define-handler";
14
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
15
+
16
+ const CIPHERTEXT = "kumiko-pii:v1:user:6b2f4a0e-1c9d-4f3a-9d2e-00000000000a:8e2Rkjj+ww==";
17
+
18
+ const leakyFeature = defineFeature("leaky", (r) => {
19
+ r.queryHandler(
20
+ defineQueryHandler({
21
+ name: "raw",
22
+ schema: z.object({}),
23
+ access: { openToAll: true },
24
+ handler: async () => ({ email: CIPHERTEXT, note: "plain" }),
25
+ }),
26
+ );
27
+ r.queryHandler(
28
+ defineQueryHandler({
29
+ name: "clean",
30
+ schema: z.object({}),
31
+ access: { openToAll: true },
32
+ handler: async () => ({ email: "marc@example.com" }),
33
+ }),
34
+ );
35
+ });
36
+
37
+ let stack: TestStack;
38
+ const originalNodeEnv = process.env["NODE_ENV"];
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [leakyFeature] });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ afterEach(() => {
49
+ resetPiiSubjectKmsForTests();
50
+ if (originalNodeEnv === undefined) delete process.env["NODE_ENV"];
51
+ else process.env["NODE_ENV"] = originalNodeEnv;
52
+ });
53
+
54
+ async function callLeakyQuery(): Promise<Response> {
55
+ const token = await stack.jwt.sign(TestUsers.admin);
56
+ return stack.http.raw(
57
+ "POST",
58
+ "/api/query",
59
+ { type: "leaky:query:raw", payload: {} },
60
+ { Authorization: `Bearer ${token}` },
61
+ );
62
+ }
63
+
64
+ describe("piiCiphertextResponseGuard", () => {
65
+ test("no KMS configured → response passes through unscanned", async () => {
66
+ const res = await callLeakyQuery();
67
+ expect(res.status).toBe(200);
68
+ expect(await res.text()).toContain(CIPHERTEXT);
69
+ });
70
+
71
+ test("KMS active, dev: leaking response becomes a loud 500", async () => {
72
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
73
+ const res = await callLeakyQuery();
74
+ expect(res.status).toBe(500);
75
+ const body = (await res.json()) as { error?: { code?: string; message?: string } };
76
+ expect(body.error?.code).toBe("pii_ciphertext_leak");
77
+ expect(body.error?.message).toContain("/api/query");
78
+ });
79
+
80
+ test("KMS active, production: leak is redacted, request succeeds", async () => {
81
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
82
+ process.env["NODE_ENV"] = "production";
83
+ const res = await callLeakyQuery();
84
+ expect(res.status).toBe(200);
85
+ const text = await res.text();
86
+ expect(text).not.toContain("kumiko-pii:");
87
+ expect(text).toContain("[pii-redacted]");
88
+ expect(text).toContain("plain");
89
+ });
90
+
91
+ test("KMS active, clean response stays untouched", async () => {
92
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
93
+ const token = await stack.jwt.sign(TestUsers.admin);
94
+ const res = await stack.http.raw(
95
+ "POST",
96
+ "/api/query",
97
+ { type: "leaky:query:clean", payload: {} },
98
+ { Authorization: `Bearer ${token}` },
99
+ );
100
+ expect(res.status).toBe(200);
101
+ expect(await res.text()).toContain("marc@example.com");
102
+ });
103
+ });
@@ -0,0 +1,45 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
3
+
4
+ const isProductionEnv = () => process.env["NODE_ENV"] === "production";
5
+ const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
6
+
7
+ // A PII subject ciphertext never belongs in an API response — its presence
8
+ // means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
9
+ // fail loud (500) so a forgotten decrypt turns the first integration test
10
+ // red; prod redacts + logs instead of shipping the blob. Skipped entirely
11
+ // when no subject KMS is configured (no ciphertexts can exist).
12
+ export function piiCiphertextResponseGuard(): MiddlewareHandler {
13
+ return async (c, next) => {
14
+ await next();
15
+ // skip: no subject KMS configured — no ciphertexts can exist, nothing to scan
16
+ if (configuredPiiSubjectKms() === undefined) return;
17
+ const contentType = c.res.headers.get("content-type") ?? "";
18
+ // skip: only JSON bodies carry handler data — streams/zips stay untouched
19
+ if (!contentType.includes("application/json")) return;
20
+ const text = await c.res.clone().text();
21
+ // skip: clean response — the common case
22
+ if (!text.includes(PII_CIPHERTEXT_PREFIX)) return;
23
+
24
+ const detail =
25
+ `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
26
+ `("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
27
+ `Decrypt before returning (decryptStoredPii / executor read path).`;
28
+ if (!isProductionEnv()) {
29
+ c.res = Response.json(
30
+ { error: { code: "pii_ciphertext_leak", httpStatus: 500, message: detail } },
31
+ { status: 500 },
32
+ );
33
+ // skip: response replaced with the loud 500 above — nothing left to do
34
+ return;
35
+ }
36
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for a redacted prod leak
37
+ console.error(detail);
38
+ const headers = new Headers(c.res.headers);
39
+ headers.delete("content-length");
40
+ c.res = new Response(text.replace(CIPHERTEXT_RE, "[pii-redacted]"), {
41
+ status: c.res.status,
42
+ headers,
43
+ });
44
+ };
45
+ }
package/src/api/server.ts CHANGED
@@ -52,6 +52,7 @@ import { csrfMiddleware } from "./csrf-middleware";
52
52
  import { createJwtHelper, type JwtHelper } from "./jwt";
53
53
  import { observabilityMiddleware } from "./observability-middleware";
54
54
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
55
+ import { piiCiphertextResponseGuard } from "./pii-leak-guard";
55
56
  import { requestIdMiddleware } from "./request-id-middleware";
56
57
  import {
57
58
  DEFAULT_MAX_REQUEST_BYTES,
@@ -553,6 +554,10 @@ export function buildServer(options: ServerOptions): KumikoServer {
553
554
  }),
554
555
  );
555
556
 
557
+ // Tripwire: a kumiko-pii: ciphertext in a JSON response is always a bug
558
+ // (raw read leaked past a decrypt) — dev fails loud, prod redacts (#820).
559
+ app.use("/api/*", piiCiphertextResponseGuard());
560
+
556
561
  // Auth middleware skips public paths (login, health) — those routes need
557
562
  // to be callable without a valid JWT. Every other /api/* request requires
558
563
  // a token (or, when anonymousAccess is wired, falls through as anonymous).
@@ -19,6 +19,7 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
22
23
  import type { EntityTableMeta } from "../db/entity-table-meta";
23
24
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
24
25
  import { camelCase as envCamelCase } from "../env";
@@ -355,6 +356,12 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
355
356
  const out: Record<string, unknown> = {};
356
357
  let changed = false;
357
358
  for (const key of Object.keys(row)) {
359
+ // Blind-Index-Spalten (#818) sind reine Lookup-Artefakte — der
360
+ // deterministische HMAC darf nie an Caller/API-Responses leaken.
361
+ if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
362
+ changed = true;
363
+ continue;
364
+ }
358
365
  const pgType = info.pgTypeOf(key);
359
366
  const value = row[key];
360
367
  let coerced: unknown = value;
@@ -529,8 +536,26 @@ function buildWhereClause(
529
536
  if (p.kind === "literal") {
530
537
  conditions.push(`${quoteIdent(col)} = ${p.literal}`);
531
538
  } else {
532
- conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
533
- values.push(p.bound);
539
+ // Blind-Index-OR-Rewrite (#818): existiert zur Spalte ein bidx-
540
+ // Pendant (Suffix-Konvention `<col>_bidx`, framework-reserviert)
541
+ // und ist ein Key konfiguriert, matcht Equality beide Arme —
542
+ // Klartext-Alt-Rows über den Plaintext-Arm, verschlüsselte Rows
543
+ // über den HMAC. Der `kumiko-pii:`-Prefix der Ciphertexte macht
544
+ // einen Zufalls-Match des Plaintext-Arms praktisch unmöglich.
545
+ const bidxKey = configuredBlindIndexKey();
546
+ const bidxCol = `${col}_bidx`;
547
+ if (
548
+ bidxKey !== undefined &&
549
+ typeof value === "string" &&
550
+ !col.endsWith("_bidx") &&
551
+ info.hasColumn(bidxCol)
552
+ ) {
553
+ conditions.push(`(${quoteIdent(col)} = $${idx++} OR ${quoteIdent(bidxCol)} = $${idx++})`);
554
+ values.push(p.bound, computeBlindIndex(bidxKey, value));
555
+ } else {
556
+ conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
557
+ values.push(p.bound);
558
+ }
534
559
  }
535
560
  }
536
561
  }
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import {
4
+ blindIndexFieldName,
5
+ collectLookupableFields,
6
+ computeBlindIndex,
7
+ computeBlindIndexValues,
8
+ configureBlindIndexKey,
9
+ configuredBlindIndexKey,
10
+ decodeBlindIndexKey,
11
+ resetBlindIndexKeyForTests,
12
+ } from "../blind-index";
13
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
14
+ import {
15
+ configurePiiSubjectKms,
16
+ encryptPiiFieldValues,
17
+ PII_ERASED_SENTINEL,
18
+ resetPiiSubjectKmsForTests,
19
+ } from "../pii-field-encryption";
20
+
21
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
22
+ const TEST_KEY_B64 = Buffer.alloc(32, 7).toString("base64");
23
+ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
24
+
25
+ const userLikeEntity = createEntity({
26
+ fields: {
27
+ email: createTextField({ required: true, pii: true, lookupable: true }),
28
+ role: createTextField(),
29
+ },
30
+ table: "bidx_users",
31
+ });
32
+
33
+ afterEach(() => {
34
+ resetBlindIndexKeyForTests();
35
+ resetPiiSubjectKmsForTests();
36
+ });
37
+
38
+ describe("computeBlindIndex", () => {
39
+ test("deterministic, prefixed, byte-exact (no normalization)", () => {
40
+ const a = computeBlindIndex(TEST_KEY, "marc@example.com");
41
+ expect(a).toStartWith("kumiko-bidx:v1:");
42
+ expect(computeBlindIndex(TEST_KEY, "marc@example.com")).toBe(a);
43
+ expect(computeBlindIndex(TEST_KEY, "Marc@example.com")).not.toBe(a);
44
+ });
45
+
46
+ test("different keys produce different indexes", () => {
47
+ const otherKey = decodeBlindIndexKey(Buffer.alloc(32, 9).toString("base64"));
48
+ expect(computeBlindIndex(otherKey, "x")).not.toBe(computeBlindIndex(TEST_KEY, "x"));
49
+ });
50
+ });
51
+
52
+ describe("configureBlindIndexKey", () => {
53
+ test("rejects keys that are not 32 bytes", () => {
54
+ expect(() => configureBlindIndexKey(Buffer.alloc(16, 1).toString("base64"))).toThrow(
55
+ /32 bytes/,
56
+ );
57
+ expect(configuredBlindIndexKey()).toBeUndefined();
58
+ });
59
+
60
+ test("undefined clears the key", () => {
61
+ configureBlindIndexKey(TEST_KEY_B64);
62
+ expect(configuredBlindIndexKey()).toBeDefined();
63
+ configureBlindIndexKey(undefined);
64
+ expect(configuredBlindIndexKey()).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("collectLookupableFields", () => {
69
+ test("only text fields with lookupable: true", () => {
70
+ expect(collectLookupableFields(userLikeEntity)).toEqual(["email"]);
71
+ });
72
+ });
73
+
74
+ describe("computeBlindIndexValues", () => {
75
+ test("no key configured → empty (blind-indexing off)", async () => {
76
+ const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
77
+ expect(out).toEqual({});
78
+ });
79
+
80
+ test("plaintext value → HMAC over the value itself", async () => {
81
+ configureBlindIndexKey(TEST_KEY_B64);
82
+ const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
83
+ expect(out[blindIndexFieldName("email")]).toBe(computeBlindIndex(TEST_KEY, "a@b.c"));
84
+ });
85
+
86
+ test("absent field stays untouched, null value → NULL bidx", async () => {
87
+ configureBlindIndexKey(TEST_KEY_B64);
88
+ expect(await computeBlindIndexValues({ role: "admin" }, ["email"])).toEqual({});
89
+ expect(await computeBlindIndexValues({ email: null }, ["email"])).toEqual({
90
+ emailBidx: null,
91
+ });
92
+ });
93
+
94
+ test("ciphertext value → decrypt via configured KMS, HMAC over plaintext", async () => {
95
+ const kms = new InMemoryKmsAdapter();
96
+ configurePiiSubjectKms(kms);
97
+ configureBlindIndexKey(TEST_KEY_B64);
98
+ const stored = await encryptPiiFieldValues(
99
+ { id: UUID_A, email: "marc@example.com" },
100
+ userLikeEntity,
101
+ ["email"],
102
+ kms,
103
+ { requestId: "test" },
104
+ );
105
+ const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
106
+ expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
107
+ });
108
+
109
+ test("erased subject → NULL bidx (lookup stops matching)", async () => {
110
+ const kms = new InMemoryKmsAdapter();
111
+ configurePiiSubjectKms(kms);
112
+ configureBlindIndexKey(TEST_KEY_B64);
113
+ const stored = await encryptPiiFieldValues(
114
+ { id: UUID_A, email: "marc@example.com" },
115
+ userLikeEntity,
116
+ ["email"],
117
+ kms,
118
+ { requestId: "test" },
119
+ );
120
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
121
+ const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
122
+ expect(out["emailBidx"]).toBeNull();
123
+ });
124
+
125
+ test("erased sentinel → NULL bidx", async () => {
126
+ configureBlindIndexKey(TEST_KEY_B64);
127
+ const out = await computeBlindIndexValues({ email: PII_ERASED_SENTINEL }, ["email"]);
128
+ expect(out["emailBidx"]).toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,117 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import postgres from "postgres";
4
+ import { createTestDb } from "../../stack/db";
5
+ import { type KmsContext, type SubjectId, subjectIdToKey } from "../kms-adapter";
6
+ import { PgKmsAdapter } from "../pg-kms-adapter";
7
+ import { describeKmsAdapterContract } from "./kms-adapter-contract";
8
+
9
+ const baseUrl = process.env["TEST_DATABASE_URL"];
10
+ if (!baseUrl) throw new Error("Missing required env var: TEST_DATABASE_URL");
11
+
12
+ const testDb = await createTestDb();
13
+ const databaseUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
14
+ const platformKek = randomBytes(32).toString("base64");
15
+ const adapter = new PgKmsAdapter({ databaseUrl, platformKek, maxConnections: 2 });
16
+ const raw = postgres(databaseUrl, { max: 1 });
17
+
18
+ afterAll(async () => {
19
+ await adapter.close();
20
+ await raw.end();
21
+ await testDb.cleanup();
22
+ });
23
+
24
+ describeKmsAdapterContract("PgKmsAdapter", async () => {
25
+ // health() creates the schema lazily, so the truncate cannot hit a
26
+ // missing table on the first run; contract tests reuse fixed subject ids.
27
+ await adapter.health();
28
+ await raw`TRUNCATE kumiko_subject_keys`;
29
+ return adapter;
30
+ });
31
+
32
+ describe("PgKmsAdapter — pg specifics", () => {
33
+ const ctx: KmsContext = { requestId: "pg-kms-test" };
34
+ const freshUser = (): SubjectId => ({ kind: "user", userId: randomUUID() });
35
+
36
+ test("rejects a platformKek that does not decode to 32 bytes", () => {
37
+ expect(
38
+ () =>
39
+ new PgKmsAdapter({
40
+ databaseUrl,
41
+ platformKek: Buffer.from("too-short").toString("base64"),
42
+ }),
43
+ ).toThrow("32 bytes");
44
+ });
45
+
46
+ test("DEKs survive across adapter instances", async () => {
47
+ const user = freshUser();
48
+ await adapter.createKey(user, ctx);
49
+ const dek = await adapter.getKey(user, ctx);
50
+
51
+ const second = new PgKmsAdapter({ databaseUrl, platformKek, maxConnections: 1 });
52
+ try {
53
+ const rehydrated = await second.getKey(user, ctx);
54
+ expect(rehydrated.equals(dek)).toBe(true);
55
+ } finally {
56
+ await second.close();
57
+ }
58
+ });
59
+
60
+ test("a different platform KEK cannot unwrap stored DEKs", async () => {
61
+ const user = freshUser();
62
+ await adapter.createKey(user, ctx);
63
+
64
+ const wrongKek = new PgKmsAdapter({
65
+ databaseUrl,
66
+ platformKek: randomBytes(32).toString("base64"),
67
+ maxConnections: 1,
68
+ });
69
+ try {
70
+ await expect(wrongKek.getKey(user, ctx)).rejects.toThrow();
71
+ } finally {
72
+ await wrongKek.close();
73
+ }
74
+ });
75
+
76
+ test("erase leaves an audit tombstone without key material", async () => {
77
+ const user = freshUser();
78
+ await adapter.createKey(user, ctx);
79
+ await adapter.eraseKey(user, {
80
+ requestId: "pg-kms-test",
81
+ userId: "operator-1",
82
+ eraseReason: "user-forget",
83
+ });
84
+
85
+ const rows = await raw`
86
+ SELECT cipher_key, erased_at, erased_by, erase_reason
87
+ FROM kumiko_subject_keys
88
+ WHERE subject_id = ${subjectIdToKey(user)}`;
89
+ expect(rows.length).toBe(1);
90
+ expect(rows[0]?.["cipher_key"]).toBeNull();
91
+ expect(rows[0]?.["erased_at"]).not.toBeNull();
92
+ expect(rows[0]?.["erased_by"]).toBe("operator-1");
93
+ expect(rows[0]?.["erase_reason"]).toBe("user-forget");
94
+ });
95
+
96
+ test("repeat erase keeps the original tombstone audit fields", async () => {
97
+ const user = freshUser();
98
+ await adapter.createKey(user, ctx);
99
+ await adapter.eraseKey(user, {
100
+ requestId: "pg-kms-test",
101
+ userId: "operator-1",
102
+ eraseReason: "user-forget",
103
+ });
104
+ await adapter.eraseKey(user, {
105
+ requestId: "pg-kms-test",
106
+ userId: "operator-2",
107
+ eraseReason: "tenant-destroy",
108
+ });
109
+
110
+ const rows = await raw`
111
+ SELECT erased_by, erase_reason
112
+ FROM kumiko_subject_keys
113
+ WHERE subject_id = ${subjectIdToKey(user)}`;
114
+ expect(rows[0]?.["erased_by"]).toBe("operator-1");
115
+ expect(rows[0]?.["erase_reason"]).toBe("user-forget");
116
+ });
117
+ });