@cosmicdrift/kumiko-framework 0.116.1 → 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.
Files changed (38) hide show
  1. package/package.json +6 -2
  2. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  3. package/src/api/pii-leak-guard.ts +45 -0
  4. package/src/api/server.ts +5 -0
  5. package/src/bun-db/query.ts +27 -2
  6. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  7. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  8. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  9. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  10. package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
  11. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  12. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  13. package/src/crypto/blind-index.ts +114 -0
  14. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  15. package/src/crypto/index.ts +55 -0
  16. package/src/crypto/kms-adapter.ts +118 -0
  17. package/src/crypto/pg-kms-adapter.ts +173 -0
  18. package/src/crypto/pii-field-encryption.ts +181 -0
  19. package/src/crypto/request-kms-cache.ts +38 -0
  20. package/src/crypto/subject-resolver.ts +91 -0
  21. package/src/db/__tests__/blind-index.integration.test.ts +232 -0
  22. package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
  23. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  24. package/src/db/apply-entity-event.ts +8 -0
  25. package/src/db/blind-index-cleanup.ts +53 -0
  26. package/src/db/entity-table-meta.ts +47 -2
  27. package/src/db/event-store-executor.ts +125 -30
  28. package/src/db/index.ts +1 -0
  29. package/src/db/table-builder.ts +98 -51
  30. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
  31. package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
  32. package/src/engine/boot-validator/pii-retention.ts +45 -9
  33. package/src/engine/define-feature.ts +3 -1
  34. package/src/engine/registry.ts +9 -0
  35. package/src/engine/types/feature.ts +10 -1
  36. package/src/engine/types/fields.ts +6 -0
  37. package/src/engine/types/handlers.ts +4 -0
  38. package/src/engine/types/index.ts +1 -0
@@ -0,0 +1,216 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
4
+ import { KeyNotFoundError, type KmsContext, subjectIdFromKey } from "../kms-adapter";
5
+ import {
6
+ decryptPiiFieldValues,
7
+ encryptPiiFieldValues,
8
+ isPiiCiphertext,
9
+ PII_ERASED_SENTINEL,
10
+ } from "../pii-field-encryption";
11
+ import { collectPiiSubjectFields } from "../subject-resolver";
12
+
13
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
14
+ const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
15
+ const KMS_CTX: KmsContext = { requestId: "test" };
16
+
17
+ const userLikeEntity = createEntity({
18
+ fields: {
19
+ email: createTextField({ required: true, pii: true }),
20
+ role: createTextField(),
21
+ },
22
+ table: "pii_users",
23
+ });
24
+
25
+ const commentEntity = createEntity({
26
+ fields: {
27
+ body: createTextField({ userOwned: { ownerField: "authorId" } }),
28
+ authorId: createTextField({ required: true }),
29
+ },
30
+ table: "pii_comments",
31
+ });
32
+
33
+ const brandingEntity = createEntity({
34
+ fields: {
35
+ brandColor: createTextField({ tenantOwned: true }),
36
+ },
37
+ table: "pii_branding",
38
+ });
39
+
40
+ describe("subjectIdFromKey", () => {
41
+ test("round-trips user and tenant keys, rejects garbage", () => {
42
+ expect(subjectIdFromKey(`user:${UUID_A}`)).toEqual({ kind: "user", userId: UUID_A });
43
+ expect(subjectIdFromKey(`tenant:${UUID_B}`)).toEqual({ kind: "tenant", tenantId: UUID_B });
44
+ expect(() => subjectIdFromKey("widget:x")).toThrow(/Invalid subject key/);
45
+ });
46
+ });
47
+
48
+ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
49
+ test("pii self-subject: DB value is sniffable ciphertext, decrypt restores plaintext", async () => {
50
+ const kms = new InMemoryKmsAdapter();
51
+ const fields = collectPiiSubjectFields(userLikeEntity);
52
+ const row = { id: UUID_A, email: "marc@example.com", role: "admin" };
53
+
54
+ const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX);
55
+ expect(isPiiCiphertext(stored["email"])).toBe(true);
56
+ expect(String(stored["email"])).toStartWith(`kumiko-pii:v1:user:${UUID_A}:`);
57
+ expect(stored["role"]).toBe("admin");
58
+ expect(row["email"]).toBe("marc@example.com");
59
+
60
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
61
+ expect(read["email"]).toBe("marc@example.com");
62
+ });
63
+
64
+ test("first write auto-creates the subject key", async () => {
65
+ const kms = new InMemoryKmsAdapter();
66
+ await expect(kms.getKey({ kind: "user", userId: UUID_A })).rejects.toBeInstanceOf(
67
+ KeyNotFoundError,
68
+ );
69
+ await encryptPiiFieldValues(
70
+ { id: UUID_A, email: "a@b.c" },
71
+ userLikeEntity,
72
+ ["email"],
73
+ kms,
74
+ KMS_CTX,
75
+ );
76
+ const dek = await kms.getKey({ kind: "user", userId: UUID_A });
77
+ expect(dek.length).toBe(32);
78
+ });
79
+
80
+ test("userOwned: ciphertext names the owner subject, not the row", async () => {
81
+ const kms = new InMemoryKmsAdapter();
82
+ const stored = await encryptPiiFieldValues(
83
+ { id: UUID_A, body: "secret note", authorId: UUID_B },
84
+ commentEntity,
85
+ ["body"],
86
+ kms,
87
+ KMS_CTX,
88
+ );
89
+ expect(String(stored["body"])).toStartWith(`kumiko-pii:v1:user:${UUID_B}:`);
90
+ });
91
+
92
+ test("tenantOwned without tenantId column falls back to write-time tenant", async () => {
93
+ const kms = new InMemoryKmsAdapter();
94
+ const stored = await encryptPiiFieldValues(
95
+ { id: UUID_A, brandColor: "#ff0000" },
96
+ brandingEntity,
97
+ ["brandColor"],
98
+ kms,
99
+ KMS_CTX,
100
+ { tenantId: UUID_B },
101
+ );
102
+ expect(String(stored["brandColor"])).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
103
+ });
104
+
105
+ test("subjectSource resolves the owner when the partial row lacks it (update changes)", async () => {
106
+ const kms = new InMemoryKmsAdapter();
107
+ const changes = { body: "edited" };
108
+ const stored = await encryptPiiFieldValues(changes, commentEntity, ["body"], kms, KMS_CTX, {
109
+ onlyKeys: ["body"],
110
+ subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
111
+ });
112
+ expect(String(stored["body"])).toStartWith(`kumiko-pii:v1:user:${UUID_B}:`);
113
+ });
114
+
115
+ test("erased subject: decrypt yields the sentinel, re-encrypt passes it through", async () => {
116
+ const kms = new InMemoryKmsAdapter();
117
+ const fields = ["email"] as const;
118
+ const stored = await encryptPiiFieldValues(
119
+ { id: UUID_A, email: "gone@example.com" },
120
+ userLikeEntity,
121
+ fields,
122
+ kms,
123
+ KMS_CTX,
124
+ );
125
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
126
+
127
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
128
+ expect(read["email"]).toBe(PII_ERASED_SENTINEL);
129
+
130
+ const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX);
131
+ expect(reStored["email"]).toBe(PII_ERASED_SENTINEL);
132
+ });
133
+
134
+ test("write to an erased subject fails (forget is permanent)", async () => {
135
+ const kms = new InMemoryKmsAdapter();
136
+ await kms.createKey({ kind: "user", userId: UUID_A });
137
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
138
+ await expect(
139
+ encryptPiiFieldValues(
140
+ { id: UUID_A, email: "new@b.c" },
141
+ userLikeEntity,
142
+ ["email"],
143
+ kms,
144
+ KMS_CTX,
145
+ ),
146
+ ).rejects.toThrow(/erased/);
147
+ });
148
+
149
+ test("legacy plaintext rows pass through decrypt unchanged", async () => {
150
+ const kms = new InMemoryKmsAdapter();
151
+ const read = await decryptPiiFieldValues(
152
+ { id: UUID_A, email: "legacy@example.com" },
153
+ ["email"],
154
+ kms,
155
+ KMS_CTX,
156
+ );
157
+ expect(read["email"]).toBe("legacy@example.com");
158
+ });
159
+
160
+ test("already-ciphertext values are not double-encrypted (re-encrypt paths)", async () => {
161
+ const kms = new InMemoryKmsAdapter();
162
+ const once = await encryptPiiFieldValues(
163
+ { id: UUID_A, email: "a@b.c" },
164
+ userLikeEntity,
165
+ ["email"],
166
+ kms,
167
+ KMS_CTX,
168
+ );
169
+ const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX);
170
+ expect(twice["email"]).toBe(once["email"]);
171
+ });
172
+
173
+ test("null values and fields outside onlyKeys are skipped", async () => {
174
+ const kms = new InMemoryKmsAdapter();
175
+ const stored = await encryptPiiFieldValues(
176
+ { id: UUID_A, email: null, role: "x" },
177
+ userLikeEntity,
178
+ ["email"],
179
+ kms,
180
+ KMS_CTX,
181
+ );
182
+ expect(stored["email"]).toBeNull();
183
+
184
+ const skipped = await encryptPiiFieldValues(
185
+ { id: UUID_A, email: "a@b.c" },
186
+ userLikeEntity,
187
+ ["email"],
188
+ kms,
189
+ KMS_CTX,
190
+ { onlyKeys: ["role"] },
191
+ );
192
+ expect(skipped["email"]).toBe("a@b.c");
193
+ });
194
+
195
+ test("non-string pii value throws", async () => {
196
+ const kms = new InMemoryKmsAdapter();
197
+ await expect(
198
+ encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX),
199
+ ).rejects.toThrow(/must be a string/);
200
+ });
201
+
202
+ test("ciphertext without a key row fails loud (wrong key store, not shredded)", async () => {
203
+ const kmsA = new InMemoryKmsAdapter();
204
+ const stored = await encryptPiiFieldValues(
205
+ { id: UUID_A, email: "a@b.c" },
206
+ userLikeEntity,
207
+ ["email"],
208
+ kmsA,
209
+ KMS_CTX,
210
+ );
211
+ const kmsB = new InMemoryKmsAdapter();
212
+ await expect(decryptPiiFieldValues(stored, ["email"], kmsB, KMS_CTX)).rejects.toBeInstanceOf(
213
+ KeyNotFoundError,
214
+ );
215
+ });
216
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
3
+ import {
4
+ KeyErasedError,
5
+ type KmsContext,
6
+ type LocalKeyKmsAdapter,
7
+ type SubjectId,
8
+ } from "../kms-adapter";
9
+ import { createRequestKmsCache } from "../request-kms-cache";
10
+
11
+ const ctx: KmsContext = { requestId: "cache-test" };
12
+ const userA: SubjectId = { kind: "user", userId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000aa" };
13
+
14
+ function countingAdapter(): { adapter: LocalKeyKmsAdapter; calls: () => number } {
15
+ const inner = new InMemoryKmsAdapter();
16
+ let getKeyCalls = 0;
17
+ const adapter: LocalKeyKmsAdapter = {
18
+ capabilities: { mode: "local-key" },
19
+ createKey: (subject) => inner.createKey(subject),
20
+ eraseKey: (subject) => inner.eraseKey(subject),
21
+ health: () => inner.health(),
22
+ getKey: (subject) => {
23
+ getKeyCalls++;
24
+ return inner.getKey(subject);
25
+ },
26
+ };
27
+ return { adapter, calls: () => getKeyCalls };
28
+ }
29
+
30
+ describe("createRequestKmsCache", () => {
31
+ test("second getKey for the same subject is served from cache", async () => {
32
+ const { adapter, calls } = countingAdapter();
33
+ await adapter.createKey(userA, ctx);
34
+ const cache = createRequestKmsCache(adapter);
35
+
36
+ const first = await cache.getKey(userA, ctx);
37
+ const second = await cache.getKey(userA, ctx);
38
+ expect(first.equals(second)).toBe(true);
39
+ expect(calls()).toBe(1);
40
+ });
41
+
42
+ test("invalidate drops the entry — next getKey hits the adapter again", async () => {
43
+ const { adapter, calls } = countingAdapter();
44
+ await adapter.createKey(userA, ctx);
45
+ const cache = createRequestKmsCache(adapter);
46
+
47
+ await cache.getKey(userA, ctx);
48
+ cache.invalidate(userA);
49
+ await cache.getKey(userA, ctx);
50
+ expect(calls()).toBe(2);
51
+ });
52
+
53
+ test("erase + invalidate surfaces KeyErasedError instead of a stale DEK", async () => {
54
+ const { adapter } = countingAdapter();
55
+ await adapter.createKey(userA, ctx);
56
+ const cache = createRequestKmsCache(adapter);
57
+
58
+ await cache.getKey(userA, ctx);
59
+ await adapter.eraseKey(userA, ctx);
60
+ cache.invalidate(userA);
61
+ await expect(cache.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyErasedError);
62
+ });
63
+
64
+ test("adapter errors are not cached — a failed lookup retries", async () => {
65
+ const { adapter, calls } = countingAdapter();
66
+ const cache = createRequestKmsCache(adapter);
67
+
68
+ await expect(cache.getKey(userA, ctx)).rejects.toThrow();
69
+ await adapter.createKey(userA, ctx);
70
+ const dek = await cache.getKey(userA, ctx);
71
+ expect(dek.length).toBe(32);
72
+ expect(calls()).toBe(2);
73
+ });
74
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import {
4
+ collectPiiSubjectFields,
5
+ resolveSubjectForField,
6
+ SubjectResolutionError,
7
+ } from "../subject-resolver";
8
+
9
+ const userLikeEntity = createEntity({
10
+ fields: {
11
+ email: createTextField({ required: true, pii: true }),
12
+ role: createTextField(),
13
+ },
14
+ table: "resolver_users",
15
+ idType: "uuid",
16
+ });
17
+
18
+ const commentEntity = createEntity({
19
+ fields: {
20
+ body: createTextField({ userOwned: { ownerField: "authorId" } }),
21
+ authorId: createTextField({ required: true }),
22
+ },
23
+ table: "resolver_comments",
24
+ });
25
+
26
+ const brandingEntity = createEntity({
27
+ fields: {
28
+ brandColor: createTextField({ tenantOwned: true }),
29
+ },
30
+ table: "resolver_branding",
31
+ });
32
+
33
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
34
+ const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
35
+
36
+ describe("resolveSubjectForField", () => {
37
+ test("pii: true → the entity row itself is the user subject", () => {
38
+ const subject = resolveSubjectForField(userLikeEntity, "email", { id: UUID_A });
39
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
40
+ });
41
+
42
+ test("pii self-subject stringifies serial ids", () => {
43
+ const subject = resolveSubjectForField(userLikeEntity, "email", { id: 42 });
44
+ expect(subject).toEqual({ kind: "user", userId: "42" });
45
+ });
46
+
47
+ test("userOwned → subject comes from the owner reference field", () => {
48
+ const subject = resolveSubjectForField(commentEntity, "body", {
49
+ id: UUID_A,
50
+ authorId: UUID_B,
51
+ });
52
+ expect(subject).toEqual({ kind: "user", userId: UUID_B });
53
+ });
54
+
55
+ test("tenantOwned → subject from the row's tenantId column", () => {
56
+ const subject = resolveSubjectForField(brandingEntity, "brandColor", {
57
+ id: UUID_A,
58
+ tenantId: UUID_B,
59
+ });
60
+ expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
61
+ });
62
+
63
+ test("tenantOwned falls back to the write-time tenantId option", () => {
64
+ const subject = resolveSubjectForField(
65
+ brandingEntity,
66
+ "brandColor",
67
+ { id: UUID_A },
68
+ { tenantId: UUID_B },
69
+ );
70
+ expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
71
+ });
72
+
73
+ test("unannotated field → null (stays plaintext)", () => {
74
+ expect(resolveSubjectForField(userLikeEntity, "role", { id: UUID_A })).toBeNull();
75
+ });
76
+
77
+ test("empty owner reference throws instead of silently falling back to plaintext", () => {
78
+ expect(() => resolveSubjectForField(commentEntity, "body", { id: UUID_A })).toThrow(
79
+ SubjectResolutionError,
80
+ );
81
+ });
82
+
83
+ test("tenantOwned without any tenant scope throws", () => {
84
+ expect(() => resolveSubjectForField(brandingEntity, "brandColor", { id: UUID_A })).toThrow(
85
+ SubjectResolutionError,
86
+ );
87
+ });
88
+
89
+ test("pii row without id throws", () => {
90
+ expect(() => resolveSubjectForField(userLikeEntity, "email", {})).toThrow(
91
+ SubjectResolutionError,
92
+ );
93
+ });
94
+
95
+ test("unknown field name throws", () => {
96
+ expect(() => resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A })).toThrow(
97
+ SubjectResolutionError,
98
+ );
99
+ });
100
+ });
101
+
102
+ describe("collectPiiSubjectFields", () => {
103
+ test("collects exactly the annotated fields", () => {
104
+ expect(collectPiiSubjectFields(userLikeEntity)).toEqual(["email"]);
105
+ expect(collectPiiSubjectFields(commentEntity)).toEqual(["body"]);
106
+ expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
107
+ });
108
+ });
@@ -0,0 +1,114 @@
1
+ // Blind-Index für PII-Equality-Lookups (kumiko-framework#818).
2
+ // `lookupable: true` auf einem Subject-annotierten text-Feld erzeugt eine
3
+ // generierte Spalte `<snake>_bidx` mit deterministischem HMAC über den
4
+ // KLARTEXT-Wert — Equality-Queries matchen `(col = $1 OR col_bidx = $2)`
5
+ // und funktionieren damit für Klartext-Alt-Rows UND verschlüsselte Rows.
6
+ //
7
+ // Wertformat: kumiko-bidx:v1:<base64url(HMAC-SHA256(key, value))>
8
+ //
9
+ // HMAC ist byte-exact ohne Normalisierung — repliziert exakt die heutige
10
+ // case-sensitive Equality-Semantik (Lowercasing wäre eine Verhaltensänderung).
11
+ //
12
+ // Der Key ist bewusst ein EIGENER 32-Byte-Key (env: KUMIKO_BLIND_INDEX_KEY),
13
+ // nicht vom PLATFORM_KEK abgeleitet und nicht Teil des KmsAdapter-Contracts —
14
+ // BYOK-Adapter dürfen ihn nie sehen. Rotation = Recompute aller bidx-Spalten
15
+ // (Rollout-Fahrplan Schritt 7).
16
+ //
17
+ // bidx-Werte leben NUR in der Projektion, nie in Event-Payloads: ein
18
+ // deterministischer HMAC im immutablen Event wäre permanente Linkage und
19
+ // bräche Forget. applyEntityEvent berechnet sie beim Apply (live + rebuild),
20
+ // nach Key-Erase recomputet der Rebuild sie zu NULL.
21
+
22
+ import { createHmac } from "node:crypto";
23
+ import { requestContext } from "../api/request-context";
24
+ import type { EntityDefinition } from "../engine/types/fields";
25
+ import {
26
+ configuredPiiSubjectKms,
27
+ decryptPiiFieldValues,
28
+ isPiiCiphertext,
29
+ PII_ERASED_SENTINEL,
30
+ } from "./pii-field-encryption";
31
+
32
+ const BLIND_INDEX_PREFIX = "kumiko-bidx:v1:";
33
+ const BLIND_INDEX_KEY_LENGTH = 32;
34
+
35
+ export function computeBlindIndex(key: Uint8Array, plaintext: string): string {
36
+ const mac = createHmac("sha256", key).update(plaintext, "utf8").digest("base64url");
37
+ return `${BLIND_INDEX_PREFIX}${mac}`;
38
+ }
39
+
40
+ export function decodeBlindIndexKey(base64Key: string): Uint8Array {
41
+ const decoded = Buffer.from(base64Key, "base64");
42
+ if (decoded.length !== BLIND_INDEX_KEY_LENGTH) {
43
+ throw new Error(
44
+ `Blind-index key must be ${BLIND_INDEX_KEY_LENGTH} bytes base64-encoded, got ${decoded.length} bytes. ` +
45
+ `Generate one: openssl rand -base64 32`,
46
+ );
47
+ }
48
+ return decoded;
49
+ }
50
+
51
+ // Boot-injected app-wide key, mirroring configurePiiSubjectKms: run{Prod,Dev}App
52
+ // call configureBlindIndexKey once; query compilers + applyEntityEvent resolve
53
+ // lazily. Absent key = blind-indexing off (bidx columns stay NULL, equality
54
+ // lookups match plaintext rows only — pre-#818 behavior).
55
+ let injectedKey: Uint8Array | undefined;
56
+
57
+ export function configureBlindIndexKey(base64Key: string | undefined): void {
58
+ injectedKey = base64Key === undefined ? undefined : decodeBlindIndexKey(base64Key);
59
+ }
60
+
61
+ export function configuredBlindIndexKey(): Uint8Array | undefined {
62
+ return injectedKey;
63
+ }
64
+
65
+ /** @internal test-only */
66
+ export function resetBlindIndexKeyForTests(): void {
67
+ injectedKey = undefined;
68
+ }
69
+
70
+ export function blindIndexFieldName(fieldName: string): string {
71
+ return `${fieldName}Bidx`;
72
+ }
73
+
74
+ // The field names that carry a blind index — precomputed per apply like
75
+ // collectPiiSubjectFields. Only text fields qualify (boot-validated).
76
+ export function collectLookupableFields(entity: EntityDefinition): readonly string[] {
77
+ return Object.entries(entity.fields)
78
+ .filter(([, field]) => field.type === "text" && field.lookupable === true)
79
+ .map(([name]) => name);
80
+ }
81
+
82
+ // bidx values for every lookupable field PRESENT in `values` (create payload
83
+ // or update changes — absent fields stay untouched). Ciphertext values are
84
+ // decrypted first so the HMAC always covers the plaintext; an erased subject
85
+ // (or the erased sentinel) yields NULL — the lookup stops matching, which is
86
+ // exactly the forget semantics.
87
+ export async function computeBlindIndexValues(
88
+ values: Record<string, unknown>,
89
+ lookupableFields: readonly string[],
90
+ ): Promise<Record<string, unknown>> {
91
+ const key = configuredBlindIndexKey();
92
+ if (key === undefined || lookupableFields.length === 0) return {};
93
+ const out: Record<string, unknown> = {};
94
+ for (const name of lookupableFields) {
95
+ if (!(name in values)) continue;
96
+ out[blindIndexFieldName(name)] = await blindIndexForValue(key, values[name]);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ async function blindIndexForValue(key: Uint8Array, value: unknown): Promise<string | null> {
102
+ if (typeof value !== "string" || value === PII_ERASED_SENTINEL) return null;
103
+ if (!isPiiCiphertext(value)) return computeBlindIndex(key, value);
104
+ const kms = configuredPiiSubjectKms();
105
+ // Ciphertext without a KMS can't be decrypted here; the same read would
106
+ // also surface raw ciphertext — misconfiguration is caught at boot.
107
+ if (kms === undefined) return null;
108
+ const decrypted = await decryptPiiFieldValues({ value }, ["value"], kms, {
109
+ requestId: requestContext.get()?.requestId ?? "blind-index",
110
+ });
111
+ const plain = decrypted["value"];
112
+ if (typeof plain !== "string" || plain === PII_ERASED_SENTINEL) return null;
113
+ return computeBlindIndex(key, plain);
114
+ }
@@ -0,0 +1,50 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ KeyAlreadyExistsError,
4
+ KeyErasedError,
5
+ KeyNotFoundError,
6
+ type KmsHealth,
7
+ type LocalKeyKmsAdapter,
8
+ type SubjectDek,
9
+ type SubjectId,
10
+ type SubjectKey,
11
+ subjectIdToKey,
12
+ } from "./kms-adapter";
13
+
14
+ interface KeyEntry {
15
+ key: Buffer | null;
16
+ erased: boolean;
17
+ }
18
+
19
+ // Non-persistent adapter for tests and dev mode. Erased entries stay as
20
+ // tombstones so the create-after-erase contract holds within a process.
21
+ export class InMemoryKmsAdapter implements LocalKeyKmsAdapter {
22
+ readonly capabilities = { mode: "local-key" } as const;
23
+
24
+ private readonly keys = new Map<SubjectKey, KeyEntry>();
25
+
26
+ async createKey(subject: SubjectId): Promise<void> {
27
+ const subjectKey = subjectIdToKey(subject);
28
+ if (this.keys.has(subjectKey)) throw new KeyAlreadyExistsError(subject);
29
+ this.keys.set(subjectKey, { key: randomBytes(32), erased: false });
30
+ }
31
+
32
+ async getKey(subject: SubjectId): Promise<SubjectDek> {
33
+ const entry = this.keys.get(subjectIdToKey(subject));
34
+ if (!entry) throw new KeyNotFoundError(subject);
35
+ if (entry.erased || entry.key === null) throw new KeyErasedError(subject);
36
+ return entry.key;
37
+ }
38
+
39
+ async eraseKey(subject: SubjectId): Promise<void> {
40
+ const entry = this.keys.get(subjectIdToKey(subject));
41
+ // skip: eraseKey is contractually idempotent — unknown subject is a no-op
42
+ if (!entry) return;
43
+ entry.key = null;
44
+ entry.erased = true;
45
+ }
46
+
47
+ async health(): Promise<KmsHealth> {
48
+ return { ok: true, latencyMs: 0 };
49
+ }
50
+ }
@@ -0,0 +1,55 @@
1
+ export {
2
+ blindIndexFieldName,
3
+ collectLookupableFields,
4
+ computeBlindIndex,
5
+ computeBlindIndexValues,
6
+ configureBlindIndexKey,
7
+ configuredBlindIndexKey,
8
+ decodeBlindIndexKey,
9
+ resetBlindIndexKeyForTests,
10
+ } from "./blind-index";
11
+ export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
12
+ export {
13
+ isLocalKeyKmsAdapter,
14
+ KeyAlreadyExistsError,
15
+ KeyErasedError,
16
+ KeyNotFoundError,
17
+ type KmsAdapter,
18
+ type KmsContext,
19
+ type KmsHealth,
20
+ type LocalKeyKmsAdapter,
21
+ type RemoteCryptoKmsAdapter,
22
+ type SubjectDek,
23
+ type SubjectId,
24
+ type SubjectKey,
25
+ subjectIdFromKey,
26
+ subjectIdToKey,
27
+ subjectKeyForTenant,
28
+ subjectKeyForUser,
29
+ } from "./kms-adapter";
30
+ export {
31
+ createPgKmsAdapter,
32
+ PgKmsAdapter,
33
+ type PgKmsAdapterOptions,
34
+ } from "./pg-kms-adapter";
35
+ export {
36
+ configuredPiiSubjectKms,
37
+ configurePiiSubjectKms,
38
+ decryptPiiFieldValues,
39
+ type EncryptPiiOptions,
40
+ encryptPiiFieldValues,
41
+ isPiiCiphertext,
42
+ PII_CIPHERTEXT_PREFIX,
43
+ PII_ERASED_SENTINEL,
44
+ resetPiiSubjectKmsForTests,
45
+ } from "./pii-field-encryption";
46
+ export {
47
+ createRequestKmsCache,
48
+ type RequestKmsCache,
49
+ } from "./request-kms-cache";
50
+ export {
51
+ collectPiiSubjectFields,
52
+ type ResolveSubjectOptions,
53
+ resolveSubjectForField,
54
+ SubjectResolutionError,
55
+ } from "./subject-resolver";