@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 +2 -2
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/pii-leak-guard.ts +45 -0
- package/src/api/server.ts +5 -0
- package/src/bun-db/query.ts +27 -2
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
- package/src/crypto/blind-index.ts +114 -0
- package/src/crypto/index.ts +27 -0
- package/src/crypto/kms-adapter.ts +8 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +181 -0
- package/src/db/__tests__/blind-index.integration.test.ts +232 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/apply-entity-event.ts +8 -0
- package/src/db/blind-index-cleanup.ts +53 -0
- package/src/db/entity-table-meta.ts +47 -2
- package/src/db/event-store-executor.ts +125 -30
- package/src/db/index.ts +1 -0
- package/src/db/table-builder.ts +98 -51
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
- package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
- package/src/engine/boot-validator/pii-retention.ts +45 -9
- package/src/engine/define-feature.ts +3 -1
- package/src/engine/registry.ts +9 -0
- package/src/engine/types/feature.ts +10 -1
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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).
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
-
|
|
533
|
-
|
|
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
|
+
});
|