@cosmicdrift/kumiko-framework 0.158.2 → 0.159.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.
Files changed (109) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  7. package/src/api/api-constants.ts +4 -0
  8. package/src/api/auth-middleware.ts +48 -59
  9. package/src/api/auth-routes.ts +51 -17
  10. package/src/api/index.ts +3 -3
  11. package/src/api/jwt.ts +148 -7
  12. package/src/api/pii-leak-guard.ts +5 -2
  13. package/src/api/server.ts +19 -5
  14. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  15. package/src/bun-db/query.ts +34 -2
  16. package/src/consumer-cli.ts +87 -0
  17. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  18. package/src/crypto/blind-index.ts +8 -4
  19. package/src/crypto/event-pii.ts +1 -0
  20. package/src/crypto/pii-field-encryption.ts +49 -15
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  22. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  23. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  24. package/src/db/blind-index-cleanup.ts +3 -1
  25. package/src/db/connection.ts +3 -11
  26. package/src/db/encryption.ts +2 -3
  27. package/src/db/entity-table-meta-types.ts +92 -0
  28. package/src/db/entity-table-meta.ts +16 -90
  29. package/src/db/queries/backfill-pii.ts +1 -0
  30. package/src/db/queries/event-consumer.ts +35 -2
  31. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  32. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  33. package/src/engine/__tests__/define-roles.test.ts +21 -0
  34. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  35. package/src/engine/__tests__/store-table.test.ts +12 -0
  36. package/src/engine/boot-validator/action-wiring.ts +1 -1
  37. package/src/engine/boot-validator/boot-check.ts +21 -0
  38. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  39. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  40. package/src/engine/boot-validator/index.ts +3 -9
  41. package/src/engine/boot-validator/screens.ts +1 -1
  42. package/src/engine/define-feature.ts +1 -0
  43. package/src/engine/define-handler.ts +10 -91
  44. package/src/engine/entity-handlers.ts +15 -27
  45. package/src/engine/feature-builder-state.ts +3 -0
  46. package/src/engine/feature-config-events-jobs.ts +1 -1
  47. package/src/engine/feature-entity-handlers.ts +1 -1
  48. package/src/engine/feature-ui-extensions.ts +5 -1
  49. package/src/engine/field-helpers.ts +31 -0
  50. package/src/engine/handler-helpers.ts +26 -0
  51. package/src/engine/hook-helpers.ts +14 -0
  52. package/src/engine/index.ts +2 -2
  53. package/src/engine/ownership.ts +22 -76
  54. package/src/engine/registry-validate.ts +1 -1
  55. package/src/engine/screen-helpers.ts +54 -0
  56. package/src/engine/tier-resolver-extension.ts +3 -2
  57. package/src/engine/types/define-handler.ts +94 -0
  58. package/src/engine/types/entity-handlers.ts +30 -0
  59. package/src/engine/types/event-type-map.ts +1 -37
  60. package/src/engine/types/feature.ts +45 -0
  61. package/src/engine/types/fields.ts +19 -31
  62. package/src/engine/types/handlers.ts +7 -26
  63. package/src/engine/types/hooks.ts +1 -15
  64. package/src/engine/types/http-route.ts +1 -72
  65. package/src/engine/types/identifiers.ts +1 -47
  66. package/src/engine/types/index.ts +34 -9
  67. package/src/engine/types/ownership.ts +83 -0
  68. package/src/engine/types/relations.ts +1 -51
  69. package/src/engine/types/screen.ts +0 -46
  70. package/src/engine/types/target-ref.ts +1 -21
  71. package/src/engine/types/tree-node.ts +1 -129
  72. package/src/entrypoint/index.ts +2 -2
  73. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  74. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  75. package/src/event-store/event-store.ts +28 -32
  76. package/src/event-store/events-schema.ts +1 -10
  77. package/src/event-store/index.ts +3 -2
  78. package/src/event-store/types.ts +22 -0
  79. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  80. package/src/files/file-handle.ts +2 -19
  81. package/src/i18n/required-surface-keys.ts +1 -1
  82. package/src/logging/types.ts +1 -7
  83. package/src/observability/types/index.ts +1 -29
  84. package/src/observability/types/metric.ts +1 -56
  85. package/src/observability/types/provider.ts +1 -32
  86. package/src/observability/types/span.ts +1 -58
  87. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  88. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  89. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  90. package/src/pipeline/dispatch-shared.ts +12 -2
  91. package/src/pipeline/entity-cache.ts +2 -33
  92. package/src/pipeline/event-consumer-state.ts +28 -3
  93. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  94. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  95. package/src/pipeline/event-dispatcher.ts +27 -1
  96. package/src/pipeline/system-hooks.ts +7 -0
  97. package/src/search/types.ts +1 -39
  98. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  99. package/src/secrets/__tests__/envelope.test.ts +1 -1
  100. package/src/secrets/envelope-cipher.ts +13 -39
  101. package/src/stack/__tests__/event-collector.test.ts +42 -0
  102. package/src/testing/__tests__/late-bound.test.ts +25 -0
  103. package/src/testing/__tests__/wait-for.test.ts +53 -0
  104. package/src/testing/boot-validator-fixture.ts +1 -1
  105. package/src/testing/file-provider-contract.ts +84 -0
  106. package/src/testing/handler-context.ts +1 -1
  107. package/src/testing/index.ts +1 -0
  108. package/src/time/geo-tz.ts +1 -32
  109. package/src/ui-types/index.ts +7 -7
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { createCipheriv, randomBytes } from "node:crypto";
2
3
  import { createEntity, createTextField } from "../../engine/factories";
3
4
  import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
4
5
  import {
@@ -62,7 +63,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
62
63
 
63
64
  const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX);
64
65
  expect(isPiiCiphertext(stored["email"])).toBe(true);
65
- expect(String(stored["email"])).toStartWith(`kumiko-pii:v1:user:${UUID_A}:`);
66
+ expect(String(stored["email"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_A}:`);
66
67
  expect(stored["role"]).toBe("admin");
67
68
  expect(row["email"]).toBe("marc@example.com");
68
69
 
@@ -95,7 +96,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
95
96
  kms,
96
97
  KMS_CTX,
97
98
  );
98
- expect(String(stored["body"])).toStartWith(`kumiko-pii:v1:user:${UUID_B}:`);
99
+ expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
99
100
  });
100
101
 
101
102
  test("tenantOwned without tenantId column falls back to write-time tenant", async () => {
@@ -108,7 +109,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
108
109
  KMS_CTX,
109
110
  { tenantId: UUID_B },
110
111
  );
111
- expect(String(stored["brandColor"])).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
112
+ expect(String(stored["brandColor"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
112
113
  });
113
114
 
114
115
  test("subjectSource resolves the owner when the partial row lacks it (update changes)", async () => {
@@ -118,7 +119,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
118
119
  onlyKeys: ["body"],
119
120
  subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
120
121
  });
121
- expect(String(stored["body"])).toStartWith(`kumiko-pii:v1:user:${UUID_B}:`);
122
+ expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
122
123
  });
123
124
 
124
125
  test("erased subject: decrypt yields the sentinel, re-encrypt passes it through", async () => {
@@ -243,7 +244,7 @@ describe("piiEncrypted alias (kumiko-platform#457)", () => {
243
244
  const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
244
245
  const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
245
246
  expect(isPiiCiphertext(stored["iban"])).toBe(true);
246
- expect(String(stored["iban"])).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
247
+ expect(String(stored["iban"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
247
248
 
248
249
  const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
249
250
  expect(read["iban"]).toBe("DE89370400440532013000");
@@ -274,27 +275,39 @@ describe("encryptPiiValueForSubject / decryptPiiValueForSubject (kumiko-platform
274
275
  test("round-trips a single value for a tenant subject", async () => {
275
276
  const kms = new InMemoryKmsAdapter();
276
277
  const subject = { kind: "tenant" as const, tenantId: UUID_B };
277
- const stored = await encryptPiiValueForSubject(kms, subject, "DE89370400440532013000", KMS_CTX);
278
+ const stored = await encryptPiiValueForSubject(
279
+ kms,
280
+ subject,
281
+ "DE89370400440532013000",
282
+ KMS_CTX,
283
+ "iban",
284
+ );
278
285
  expect(isPiiCiphertext(stored)).toBe(true);
279
- expect(stored).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
286
+ expect(stored).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
280
287
 
281
- const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
288
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX, "iban");
282
289
  expect(read).toBe("DE89370400440532013000");
283
290
  });
284
291
 
285
292
  test("erased subject: decrypt yields the sentinel", async () => {
286
293
  const kms = new InMemoryKmsAdapter();
287
294
  const subject = { kind: "user" as const, userId: UUID_A };
288
- const stored = await encryptPiiValueForSubject(kms, subject, "+49 151 00000000", KMS_CTX);
295
+ const stored = await encryptPiiValueForSubject(
296
+ kms,
297
+ subject,
298
+ "+49 151 00000000",
299
+ KMS_CTX,
300
+ "phone",
301
+ );
289
302
  await kms.eraseKey(subject);
290
303
 
291
- const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
304
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX, "phone");
292
305
  expect(read).toBe(PII_ERASED_SENTINEL);
293
306
  });
294
307
 
295
308
  test("plaintext passes through decrypt unchanged (pre-engine rows)", async () => {
296
309
  const kms = new InMemoryKmsAdapter();
297
- const read = await decryptPiiValueForSubject(kms, "plain-value", KMS_CTX);
310
+ const read = await decryptPiiValueForSubject(kms, "plain-value", KMS_CTX, "value");
298
311
  expect(read).toBe("plain-value");
299
312
  });
300
313
  });
@@ -308,13 +321,56 @@ describe("cross-subject decrypt leak (kumiko-framework#1190)", () => {
308
321
  // existing "ciphertext without a key row fails loud" (KeyNotFoundError) case.
309
322
  await kms.createKey(subjectA);
310
323
 
311
- const storedForB = await encryptPiiValueForSubject(kms, subjectB, "tenant-b-secret", KMS_CTX);
324
+ const storedForB = await encryptPiiValueForSubject(
325
+ kms,
326
+ subjectB,
327
+ "tenant-b-secret",
328
+ KMS_CTX,
329
+ "secret",
330
+ );
312
331
  const blob = storedForB.slice(storedForB.lastIndexOf(":") + 1);
313
332
  const forgedForA = `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subjectA)}:${blob}`;
314
333
 
315
- const attempt = decryptPiiValueForSubject(kms, forgedForA, KMS_CTX);
334
+ const attempt = decryptPiiValueForSubject(kms, forgedForA, KMS_CTX, "secret");
316
335
  await expect(attempt).rejects.not.toBeInstanceOf(KeyNotFoundError);
317
336
  await expect(attempt).rejects.not.toBeInstanceOf(KeyErasedError);
318
337
  await expect(attempt).rejects.toThrow();
319
338
  });
320
339
  });
340
+
341
+ describe("cross-field decrypt leak (kumiko-framework#1263)", () => {
342
+ test("ciphertext cut-and-pasted into a different field of the SAME subject fails GCM auth", async () => {
343
+ const kms = new InMemoryKmsAdapter();
344
+ const subject = { kind: "user" as const, userId: UUID_A };
345
+ const storedAsEmail = await encryptPiiValueForSubject(
346
+ kms,
347
+ subject,
348
+ "secret@example.com",
349
+ KMS_CTX,
350
+ "email",
351
+ );
352
+
353
+ // Same subject, same DEK — only the declared field differs. Key
354
+ // selection alone can't catch this; AAD must.
355
+ const attempt = decryptPiiValueForSubject(kms, storedAsEmail, KMS_CTX, "phone");
356
+ await expect(attempt).rejects.toThrow();
357
+ });
358
+
359
+ test("legacy v1 ciphertext (no AAD) still decrypts under the matching subject/field", async () => {
360
+ const kms = new InMemoryKmsAdapter();
361
+ const subject = { kind: "user" as const, userId: UUID_A };
362
+ await kms.createKey(subject);
363
+ const dek = await kms.getKey(subject);
364
+
365
+ // Hand-roll a pre-#1263 v1 blob (no setAAD) — real pre-migration rows
366
+ // look exactly like this.
367
+ const iv = randomBytes(12);
368
+ const cipher = createCipheriv("aes-256-gcm", dek, iv);
369
+ const ciphertext = Buffer.concat([cipher.update("legacy-value", "utf8"), cipher.final()]);
370
+ const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
371
+ const v1 = `kumiko-pii:v1:${subjectIdToKey(subject)}:${blob.toString("base64")}`;
372
+
373
+ const read = await decryptPiiValueForSubject(kms, v1, KMS_CTX, "email");
374
+ expect(read).toBe("legacy-value");
375
+ });
376
+ });
@@ -93,22 +93,26 @@ export async function computeBlindIndexValues(
93
93
  const out: Record<string, unknown> = {};
94
94
  for (const name of lookupableFields) {
95
95
  if (!(name in values)) continue;
96
- out[blindIndexFieldName(name)] = await blindIndexForValue(key, values[name]);
96
+ out[blindIndexFieldName(name)] = await blindIndexForValue(key, name, values[name]);
97
97
  }
98
98
  return out;
99
99
  }
100
100
 
101
- async function blindIndexForValue(key: Uint8Array, value: unknown): Promise<string | null> {
101
+ async function blindIndexForValue(
102
+ key: Uint8Array,
103
+ field: string,
104
+ value: unknown,
105
+ ): Promise<string | null> {
102
106
  if (typeof value !== "string" || value === PII_ERASED_SENTINEL) return null;
103
107
  if (!isPiiCiphertext(value)) return computeBlindIndex(key, value);
104
108
  const kms = configuredPiiSubjectKms();
105
109
  // Ciphertext without a KMS can't be decrypted here; the same read would
106
110
  // also surface raw ciphertext — misconfiguration is caught at boot.
107
111
  if (kms === undefined) return null;
108
- const decrypted = await decryptPiiFieldValues({ value }, ["value"], kms, {
112
+ const decrypted = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
109
113
  requestId: requestContext.get()?.requestId ?? "blind-index",
110
114
  });
111
- const plain = decrypted["value"];
115
+ const plain = decrypted[field];
112
116
  if (typeof plain !== "string" || plain === PII_ERASED_SENTINEL) return null;
113
117
  return computeBlindIndex(key, plain);
114
118
  }
@@ -59,6 +59,7 @@ export async function encryptEventPayloadPii(
59
59
  { kind: "user", userId: subjectId },
60
60
  value,
61
61
  { requestId: requestContext.get()?.requestId ?? "append-event" },
62
+ field,
62
63
  );
63
64
  if (encrypted !== value) {
64
65
  out ??= { ...payload };
@@ -3,7 +3,12 @@
3
3
  // erase subject — kms.eraseKey(subject) makes every value unreadable at once.
4
4
  // Storage format is a sniffable string that fits existing text columns and
5
5
  // names its subject inline, so decrypt needs no schema change and no resolver:
6
- // kumiko-pii:v1:<subjectKey>:<base64(iv|tag|ciphertext)>
6
+ // kumiko-pii:v2:<subjectKey>:<base64(iv|tag|ciphertext)>
7
+ // v2 GCM-binds the ciphertext to `subjectKey|field` as AAD (#1263) — subject
8
+ // selects the DEK, field stops a cut-and-paste between two fields of the
9
+ // SAME subject (same DEK, so key selection alone can't catch that) from
10
+ // decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
11
+ // every new write emits v2.
7
12
 
8
13
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9
14
  import type { EntityDefinition } from "../engine/types/fields";
@@ -26,24 +31,40 @@ import { resolveSubjectForField } from "./subject-resolver";
26
31
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
27
32
  export const PII_ERASED_SENTINEL = "[[erased]]";
28
33
 
29
- export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v1:";
34
+ const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
35
+ export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
30
36
  const IV_LENGTH = 12;
31
37
  const AUTH_TAG_LENGTH = 16;
32
38
 
33
39
  export function isPiiCiphertext(value: unknown): value is string {
34
- return typeof value === "string" && value.startsWith(PII_CIPHERTEXT_PREFIX);
40
+ return (
41
+ typeof value === "string" &&
42
+ (value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1))
43
+ );
35
44
  }
36
45
 
37
- function encryptValue(subject: SubjectId, dek: SubjectDek, plaintext: string): string {
46
+ function buildAad(subject: SubjectId, field: string): Buffer {
47
+ return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
48
+ }
49
+
50
+ function encryptValue(
51
+ subject: SubjectId,
52
+ dek: SubjectDek,
53
+ plaintext: string,
54
+ field: string,
55
+ ): string {
38
56
  const iv = randomBytes(IV_LENGTH);
39
57
  const cipher = createCipheriv("aes-256-gcm", dek, iv);
58
+ cipher.setAAD(buildAad(subject, field));
40
59
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
41
60
  const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
42
61
  return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
43
62
  }
44
63
 
45
- function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer } {
46
- const rest = value.slice(PII_CIPHERTEXT_PREFIX.length);
64
+ function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer; hasAad: boolean } {
65
+ const hasAad = value.startsWith(PII_CIPHERTEXT_PREFIX);
66
+ const prefix = hasAad ? PII_CIPHERTEXT_PREFIX : PII_CIPHERTEXT_PREFIX_V1;
67
+ const rest = value.slice(prefix.length);
47
68
  // subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
48
69
  // last ":" is always the key/blob separator.
49
70
  const sep = rest.lastIndexOf(":");
@@ -51,14 +72,22 @@ function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer } {
51
72
  return {
52
73
  subject: subjectIdFromKey(rest.slice(0, sep)),
53
74
  blob: Buffer.from(rest.slice(sep + 1), "base64"),
75
+ hasAad,
54
76
  };
55
77
  }
56
78
 
57
- function decryptValue(dek: SubjectDek, blob: Buffer): string {
79
+ function decryptValue(
80
+ dek: SubjectDek,
81
+ blob: Buffer,
82
+ subject: SubjectId,
83
+ field: string,
84
+ hasAad: boolean,
85
+ ): string {
58
86
  const iv = blob.subarray(0, IV_LENGTH);
59
87
  const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
60
88
  const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
61
89
  const decipher = createDecipheriv("aes-256-gcm", dek, iv);
90
+ if (hasAad) decipher.setAAD(buildAad(subject, field));
62
91
  decipher.setAuthTag(tag);
63
92
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
64
93
  }
@@ -87,31 +116,36 @@ async function getOrCreateDek(
87
116
 
88
117
  // Single-value encrypt for callers that resolve the subject themselves
89
118
  // (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
90
- // idempotent like the field-map variant.
119
+ // idempotent like the field-map variant. `field` names the value's storage
120
+ // slot (payload field / config key) — part of the AAD, so it must match the
121
+ // `field` passed to decryptPiiValueForSubject for the same value.
91
122
  export async function encryptPiiValueForSubject(
92
123
  kms: LocalKeyKmsAdapter,
93
124
  subject: SubjectId,
94
125
  value: string,
95
126
  kmsCtx: KmsContext,
127
+ field: string,
96
128
  ): Promise<string> {
97
129
  if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
98
130
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
99
- return encryptValue(subject, dek, value);
131
+ return encryptValue(subject, dek, value, field);
100
132
  }
101
133
 
102
134
  // Single-value decrypt for callers that don't have an entity/field-map to
103
135
  // pass through decryptPiiFieldValues (config values). The subject lives
104
136
  // inside the ciphertext itself, so no subject/scope resolution is needed
105
- // on this side — only the encrypt direction has to pick one.
137
+ // on this side — only the encrypt direction has to pick one. `field` must
138
+ // match the one used at encrypt time (AAD) or decrypt fails loud.
106
139
  export async function decryptPiiValueForSubject(
107
140
  kms: LocalKeyKmsAdapter,
108
141
  value: string,
109
142
  kmsCtx: KmsContext,
143
+ field: string,
110
144
  ): Promise<string> {
111
145
  if (!isPiiCiphertext(value)) return value;
112
- const { subject, blob } = parseCiphertext(value);
146
+ const { subject, blob, hasAad } = parseCiphertext(value);
113
147
  try {
114
- return decryptValue(await kms.getKey(subject, kmsCtx), blob);
148
+ return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
115
149
  } catch (e) {
116
150
  if (!(e instanceof KeyErasedError)) throw e;
117
151
  return PII_ERASED_SENTINEL;
@@ -156,7 +190,7 @@ export async function encryptPiiFieldValues(
156
190
  // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
157
191
  if (subject === null) continue;
158
192
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
159
- out[name] = encryptValue(subject, dek, value);
193
+ out[name] = encryptValue(subject, dek, value, name);
160
194
  }
161
195
  return out;
162
196
  }
@@ -174,9 +208,9 @@ export async function decryptPiiFieldValues(
174
208
  // Pre-engine plaintext rows pass through unchanged (mixed-state reads
175
209
  // work during rollout; backfill is tracked in kumiko-framework#799).
176
210
  if (!isPiiCiphertext(value)) continue;
177
- const { subject, blob } = parseCiphertext(value);
211
+ const { subject, blob, hasAad } = parseCiphertext(value);
178
212
  try {
179
- out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob);
213
+ out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, name, hasAad);
180
214
  } catch (e) {
181
215
  // KeyNotFound deliberately propagates: ciphertext without a key row
182
216
  // means the key store is wrong (not shredded) — fail loud.
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decryptPiiFieldValues,
4
+ InMemoryKmsAdapter,
5
+ isPiiCiphertext,
6
+ type KmsContext,
7
+ } from "../../crypto";
8
+ import { createEntity, createTextField } from "../../engine";
9
+ import { createTestUser, testUserId } from "../../stack/test-users";
10
+ import { createTestEnvelopeCipher } from "../../testing";
11
+ import { buildExecutorContext, type Table } from "../event-store-executor-context";
12
+
13
+ const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
14
+
15
+ describe("event-store-executor-context — encryptForStorage/decryptForRead layering", () => {
16
+ const entity = createEntity({
17
+ table: "pii_roundtrip_test",
18
+ fields: {
19
+ userId: createTextField({ required: true }),
20
+ // Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
21
+ // that first surfaced the ordering bug (pii-subject-encryption
22
+ // integration test).
23
+ secretNote: createTextField({ encrypted: true, userOwned: { ownerField: "userId" } }),
24
+ },
25
+ });
26
+ const encryption = createTestEnvelopeCipher(TEST_KEY);
27
+ const kms = new InMemoryKmsAdapter();
28
+ const kmsCtx: KmsContext = { requestId: "test" };
29
+ const context = buildExecutorContext({} as Table, entity, {
30
+ entityName: "piiRoundtripTest",
31
+ encryption,
32
+ kms,
33
+ });
34
+ const user = createTestUser({ id: testUserId(1) });
35
+
36
+ test("round-trips through both layers", async () => {
37
+ const row = { userId: user.id, secretNote: "the actual secret" };
38
+ const stored = await context.encryptForStorage(row, user);
39
+ expect(stored["secretNote"]).not.toBe("the actual secret");
40
+
41
+ const read = await context.decryptForRead(stored);
42
+ expect(read["secretNote"]).toBe("the actual secret");
43
+ });
44
+
45
+ test("stores PII(envelope(plaintext)) — PII is the outer layer, not envelope", async () => {
46
+ const row = { userId: user.id, secretNote: "the actual secret" };
47
+ const stored = await context.encryptForStorage(row, user);
48
+ const storedNote = stored["secretNote"];
49
+
50
+ // Outer layer is PII ciphertext.
51
+ expect(isPiiCiphertext(storedNote)).toBe(true);
52
+
53
+ // Peeling only the outer (PII) layer must leave an envelope-ciphertext
54
+ // string underneath, not the plaintext directly — proves encryptForStorage
55
+ // wrapped the envelope-ciphertext with PII, not the raw plaintext.
56
+ const piiPeeled = await decryptPiiFieldValues(stored, ["secretNote"], kms, kmsCtx);
57
+ const innerValue = piiPeeled["secretNote"];
58
+ expect(innerValue).not.toBe("the actual secret");
59
+ expect(isPiiCiphertext(innerValue)).toBe(false);
60
+ await expect(encryption.decrypt(innerValue as string)).resolves.toBe("the actual secret");
61
+
62
+ // decryptForRead must peel PII first: feeding the stored value straight
63
+ // to the envelope cipher (skipping the PII unwrap) has to fail — this is
64
+ // the ordering bug the roundtrip guards against.
65
+ await expect(encryption.decrypt(storedNote as string)).rejects.toThrow();
66
+ });
67
+ });