@cosmicdrift/kumiko-framework 0.110.0 → 0.111.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.110.0",
3
+ "version": "0.111.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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.110.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.111.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -10,10 +10,11 @@ import {
10
10
  SYSTEM_TENANT_ID,
11
11
  } from "../../engine";
12
12
  import type { ConfigSeedDef, Registry } from "../../engine/types";
13
+ import { createEnvMasterKeyProvider } from "../../secrets/env-master-key-provider";
14
+ import { createEnvelopeCipher } from "../../secrets/envelope-cipher";
13
15
  import { unsafeCreateEntityTable } from "../../stack";
14
16
  import { ensureTemporalPolyfill } from "../../time/polyfill";
15
17
  import { seedConfigValues } from "../config-seed";
16
- import { createEncryptionProvider } from "../encryption";
17
18
  import { buildEntityTable } from "../table-builder";
18
19
 
19
20
  // --- Test Entity ---
@@ -51,8 +52,15 @@ const mockRegistry = {
51
52
  } as unknown as Registry;
52
53
 
53
54
  // --- Helpers ---
54
- const encryption = createEncryptionProvider(
55
- Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"),
55
+ const encryption = createEnvelopeCipher(
56
+ createEnvMasterKeyProvider({
57
+ env: {
58
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
59
+ KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.from("0123456789abcdef0123456789abcdef").toString(
60
+ "base64",
61
+ ),
62
+ },
63
+ }),
56
64
  );
57
65
 
58
66
  let testDb: BunTestDb;
@@ -214,7 +222,7 @@ describe("seedConfigValues", () => {
214
222
 
215
223
  await expect(
216
224
  seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
217
- ).rejects.toThrow(/encrypted but no EncryptionProvider/);
225
+ ).rejects.toThrow(/encrypted but no EnvelopeCipher/);
218
226
  });
219
227
 
220
228
  test("encrypted seed with provider stores ciphertext, not plaintext", async () => {
@@ -240,7 +248,7 @@ describe("seedConfigValues", () => {
240
248
  // resolver later runs `decrypt → JSON.parse` to get the primitive
241
249
  // back; we replay the same round-trip here.
242
250
  expect(row!.value).not.toContain("sk_live_secret_token");
243
- expect(JSON.parse(encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
251
+ expect(JSON.parse(await encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
244
252
  });
245
253
 
246
254
  test("race-safe parallel boot — two concurrent calls result in 1 created + 1 skipped", async () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
- import { createEncryptionProvider } from "../encryption";
3
+ import { createTestEnvelopeCipher } from "../../testing";
4
4
  import {
5
5
  collectEncryptedFieldNames,
6
6
  decryptEntityFieldValues,
@@ -18,25 +18,25 @@ describe("entity-field-encryption", () => {
18
18
  },
19
19
  });
20
20
  const encryptedFields = collectEncryptedFieldNames(entity);
21
- const encryption = createEncryptionProvider(TEST_KEY);
21
+ const encryption = createTestEnvelopeCipher(TEST_KEY);
22
22
 
23
23
  test("collectEncryptedFieldNames finds encrypted text fields only", () => {
24
24
  expect([...encryptedFields]).toEqual(["secretNote"]);
25
25
  });
26
26
 
27
- test("encrypt on write / decrypt on read round-trip", () => {
27
+ test("encrypt on write / decrypt on read round-trip", async () => {
28
28
  const plain = { email: "a@b.de", secretNote: "top secret" };
29
- const stored = encryptEntityFieldValues(plain, encryptedFields, encryption);
29
+ const stored = await encryptEntityFieldValues(plain, encryptedFields, encryption);
30
30
  expect(stored["email"]).toBe(plain.email);
31
31
  expect(stored["secretNote"]).not.toBe("top secret");
32
32
 
33
- const read = decryptEntityFieldValues(stored, encryptedFields, encryption);
33
+ const read = await decryptEntityFieldValues(stored, encryptedFields, encryption);
34
34
  expect(read).toEqual(plain);
35
35
  });
36
36
 
37
- test("onlyKeys limits encryption to changed fields", () => {
37
+ test("onlyKeys limits encryption to changed fields", async () => {
38
38
  const row = { email: "a@b.de", secretNote: "note" };
39
- const stored = encryptEntityFieldValues(row, encryptedFields, encryption, {
39
+ const stored = await encryptEntityFieldValues(row, encryptedFields, encryption, {
40
40
  onlyKeys: ["secretNote"],
41
41
  });
42
42
  expect(stored["email"]).toBe("a@b.de");
@@ -10,7 +10,7 @@ import {
10
10
  testTenantId,
11
11
  unsafeCreateEntityTable,
12
12
  } from "../../stack";
13
- import { createEncryptionProvider } from "../encryption";
13
+ import { createTestEnvelopeCipher } from "../../testing";
14
14
  import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
15
15
  import { createEventStoreExecutor } from "../event-store-executor";
16
16
  import { buildEntityTable } from "../table-builder";
@@ -324,7 +324,7 @@ const encryptedSoftDeleteEntity = createEntity({
324
324
  const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
325
325
 
326
326
  describe("event-store-executor — encrypted fields", () => {
327
- const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
327
+ const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
328
328
  const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
329
329
  entityName: "esExecEncrypted",
330
330
  encryption,
@@ -337,7 +337,6 @@ describe("event-store-executor — encrypted fields", () => {
337
337
  );
338
338
 
339
339
  beforeAll(async () => {
340
- process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
341
340
  resetEntityFieldEncryptionCacheForTests();
342
341
  await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
343
342
  await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
@@ -524,7 +523,7 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
524
523
  store.delete(`${tenantId}:${name}:${id}`);
525
524
  },
526
525
  };
527
- const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
526
+ const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
528
527
  const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
529
528
  entityName: "esExecEncrypted",
530
529
  entityCache,
@@ -2,8 +2,8 @@ import { v5 as uuidv5 } from "uuid";
2
2
  import type { EntityDefinition } from "../engine";
3
3
  import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
4
4
  import type { ConfigSeedDef, Registry } from "../engine/types";
5
+ import type { EnvelopeCipher } from "../secrets/envelope-cipher";
5
6
  import type { DbConnection } from "./connection";
6
- import type { EncryptionProvider } from "./encryption";
7
7
  import { createEventStoreExecutor } from "./event-store-executor";
8
8
  import type { EntityTable } from "./table-builder";
9
9
  import { createTenantDb } from "./tenant-db";
@@ -30,7 +30,7 @@ export async function seedConfigValues<E extends EntityDefinition>(
30
30
  entity: E,
31
31
  registry: Registry,
32
32
  db: DbConnection,
33
- encryption?: EncryptionProvider,
33
+ cipher?: EnvelopeCipher,
34
34
  ): Promise<{ created: number; skipped: number }> {
35
35
  let created = 0;
36
36
  let skipped = 0;
@@ -48,12 +48,12 @@ export async function seedConfigValues<E extends EntityDefinition>(
48
48
  continue;
49
49
  }
50
50
 
51
- // Encrypted keys without an encryption provider would silently write
52
- // plaintext to a column the resolver later tries to decrypt — fail
53
- // loud at boot, not on first read in prod.
54
- if (keyDef.encrypted && !encryption) {
51
+ // Encrypted keys without a cipher would silently write plaintext to a
52
+ // column the resolver later tries to decrypt — fail loud at boot, not
53
+ // on first read in prod.
54
+ if (keyDef.encrypted && !cipher) {
55
55
  throw new Error(
56
- `seedConfigValues: key "${seed.key}" is encrypted but no EncryptionProvider was supplied.`,
56
+ `seedConfigValues: key "${seed.key}" is encrypted but no EnvelopeCipher was supplied.`,
57
57
  );
58
58
  }
59
59
 
@@ -77,8 +77,8 @@ export async function seedConfigValues<E extends EntityDefinition>(
77
77
  const aggregateId = uuidv5(idSource, CONFIG_SEED_NS);
78
78
 
79
79
  let value = JSON.stringify(seed.value);
80
- if (keyDef.encrypted && encryption) {
81
- value = encryption.encrypt(value);
80
+ if (keyDef.encrypted && cipher) {
81
+ value = await cipher.encrypt(value, { tenantId });
82
82
  }
83
83
 
84
84
  const payload: Record<string, unknown> = {
@@ -9,6 +9,13 @@ export type EncryptionProvider = {
9
9
  decrypt(ciphertext: string): string;
10
10
  };
11
11
 
12
+ /**
13
+ * @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
14
+ * a key change makes existing ciphertexts permanently undecryptable. Use
15
+ * `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code; this
16
+ * provider remains only as the decrypt-fallback for pre-envelope values
17
+ * (EnvelopeCipherOptions.legacy) until a re-encrypt job migrated them.
18
+ */
12
19
  export function createEncryptionProvider(key: string): EncryptionProvider {
13
20
  // Key must be 32 bytes for AES-256
14
21
  const keyBuffer = Buffer.from(key, "base64");
@@ -1,6 +1,13 @@
1
- import type { EntityDefinition } from "../engine/types";
2
- import type { EncryptionProvider } from "./encryption";
3
- import { createEncryptionProvider } from "./encryption";
1
+ // Entity-field encryption (`encrypted: true` on text/longText fields),
2
+ // backed by the same envelope cipher as encrypted config values: JSON
3
+ // StoredEnvelope in the TEXT column, kekVersion per value, legacy
4
+ // single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
5
+ // the cipher's legacy fallback until re-encrypted.
6
+
7
+ import type { EntityDefinition, TenantId } from "../engine/types";
8
+ import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider";
9
+ import type { EnvelopeCipher } from "../secrets/envelope-cipher";
10
+ import type { KeyScope } from "../secrets/types";
4
11
 
5
12
  export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string> {
6
13
  const names = new Set<string>();
@@ -12,70 +19,120 @@ export function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySe
12
19
  return names;
13
20
  }
14
21
 
15
- function encryptFieldValue(
22
+ // BYOK hook: thread the row's tenant to the cipher when the row carries one
23
+ // (system-scope rows may not).
24
+ function scopeOf(row: Record<string, unknown>): KeyScope | undefined {
25
+ const tenantId = row["tenantId"];
26
+ return typeof tenantId === "string" ? { tenantId: tenantId as TenantId } : undefined; // @cast-boundary db-row
27
+ }
28
+
29
+ async function encryptFieldValue(
16
30
  fieldName: string,
17
31
  value: unknown,
18
- encryption: EncryptionProvider,
19
- ): string {
32
+ cipher: EnvelopeCipher,
33
+ scope: KeyScope | undefined,
34
+ ): Promise<string> {
20
35
  if (value === null || value === undefined) {
21
36
  throw new Error(`encrypted field "${fieldName}" cannot be null or undefined`);
22
37
  }
23
38
  if (typeof value !== "string") {
24
39
  throw new Error(`encrypted field "${fieldName}" must be a string, got ${typeof value}`);
25
40
  }
26
- return encryption.encrypt(value);
41
+ return cipher.encrypt(value, scope);
27
42
  }
28
43
 
29
- export function encryptEntityFieldValues(
44
+ export async function encryptEntityFieldValues(
30
45
  row: Record<string, unknown>,
31
46
  encryptedFields: ReadonlySet<string>,
32
- encryption: EncryptionProvider,
47
+ cipher: EnvelopeCipher,
33
48
  opts?: { onlyKeys?: Iterable<string> },
34
- ): Record<string, unknown> {
49
+ ): Promise<Record<string, unknown>> {
35
50
  if (encryptedFields.size === 0) return row;
36
51
  const only = opts?.onlyKeys ? new Set(opts.onlyKeys) : null;
37
52
  const out = { ...row };
53
+ const scope = scopeOf(row);
38
54
  for (const name of encryptedFields) {
39
55
  if (only && !only.has(name)) continue;
40
56
  if (!(name in out)) continue;
41
57
  const value = out[name];
42
58
  if (value === null || value === undefined) continue;
43
- out[name] = encryptFieldValue(name, value, encryption);
59
+ out[name] = await encryptFieldValue(name, value, cipher, scope);
44
60
  }
45
61
  return out;
46
62
  }
47
63
 
48
- export function decryptEntityFieldValues(
64
+ export async function decryptEntityFieldValues(
49
65
  row: Record<string, unknown>,
50
66
  encryptedFields: ReadonlySet<string>,
51
- encryption: EncryptionProvider,
52
- ): Record<string, unknown> {
67
+ cipher: EnvelopeCipher,
68
+ ): Promise<Record<string, unknown>> {
53
69
  if (encryptedFields.size === 0) return row;
54
70
  const out = { ...row };
71
+ const scope = scopeOf(row);
55
72
  for (const name of encryptedFields) {
56
73
  const value = out[name];
57
74
  if (value === null || value === undefined) continue;
58
75
  if (typeof value !== "string") continue;
59
- out[name] = encryption.decrypt(value);
76
+ out[name] = await cipher.decrypt(value, scope);
60
77
  }
61
78
  return out;
62
79
  }
63
80
 
64
- let cachedProvider: EncryptionProvider | undefined;
81
+ // Boot-injected app-wide cipher. run{Prod,Dev}App (and test setups that
82
+ // exercise encrypted fields) call configureEntityFieldEncryption once with
83
+ // the same cipher instance the config feature uses; executors resolve it
84
+ // lazily so entities without encrypted fields never touch it.
85
+ let injectedCipher: EnvelopeCipher | undefined;
65
86
 
66
- export function resolveEntityFieldEncryption(): EncryptionProvider {
67
- if (cachedProvider) return cachedProvider;
68
- const key = process.env["ENCRYPTION_KEY"];
69
- if (!key) {
87
+ export function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void {
88
+ injectedCipher = cipher;
89
+ }
90
+
91
+ export function resolveEntityFieldEncryption(): EnvelopeCipher {
92
+ if (!injectedCipher) {
70
93
  throw new Error(
71
- "ENCRYPTION_KEY environment variable is required (encrypted entity fields in use)",
94
+ "entity-field encryption is not configured — encrypted entity fields need a master key " +
95
+ "(KUMIKO_SECRETS_MASTER_KEY_V<n>); run{Prod,Dev}App wire the cipher automatically, " +
96
+ "custom boots call configureEntityFieldEncryption(cipher).",
72
97
  );
73
98
  }
74
- cachedProvider = createEncryptionProvider(key);
75
- return cachedProvider;
99
+ return injectedCipher;
100
+ }
101
+
102
+ // Non-throwing probe for callers that degrade gracefully (DSGVO export
103
+ // replaces encrypted fields with an explicit marker instead of failing
104
+ // the whole export when no cipher is wired).
105
+ export function configuredEntityFieldEncryption(): EnvelopeCipher | undefined {
106
+ return injectedCipher;
76
107
  }
77
108
 
78
109
  /** @internal test-only */
79
110
  export function resetEntityFieldEncryptionCacheForTests(): void {
80
- cachedProvider = undefined;
111
+ injectedCipher = undefined;
112
+ }
113
+
114
+ // Boot-time availability probe for validateBoot: either a cipher was
115
+ // injected, or the env keyring must be constructible — eager construction
116
+ // surfaces missing AND malformed keys (wrong length, bad base64) at boot
117
+ // instead of on the first encrypted read in prod. validateBoot runs before
118
+ // configureEntityFieldEncryption, so the env probe is the common path.
119
+ export function validateEntityFieldEncryptionAvailable(): void {
120
+ // skip: an injected cipher (test seam / custom KMS) satisfies availability
121
+ if (injectedCipher) return;
122
+ try {
123
+ createEnvMasterKeyProvider({
124
+ env: {
125
+ ...process.env,
126
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
127
+ process.env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
128
+ },
129
+ });
130
+ } catch (err) {
131
+ const reason = err instanceof Error ? err.message : String(err);
132
+ throw new Error(
133
+ `encrypted entity fields in use but no usable master key (${reason}) — set ` +
134
+ "KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or inject a cipher via " +
135
+ "configureEntityFieldEncryption().",
136
+ );
137
+ }
81
138
  }
@@ -39,13 +39,13 @@ import {
39
39
  } from "../event-store";
40
40
  import type { EntityCache } from "../pipeline/entity-cache";
41
41
  import type { SearchAdapter } from "../search/types";
42
+ import type { EnvelopeCipher } from "../secrets/envelope-cipher";
42
43
  import { assertUnreachable, generateId } from "../utils";
43
44
  import { applyEntityEvent } from "./apply-entity-event";
44
45
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
45
46
  import type { DbRow } from "./connection";
46
47
  import { decodeCursor, encodeCursor } from "./cursor";
47
48
  import type { TableColumns } from "./dialect";
48
- import type { EncryptionProvider } from "./encryption";
49
49
  import {
50
50
  collectEncryptedFieldNames,
51
51
  decryptEntityFieldValues,
@@ -119,8 +119,8 @@ export type EventStoreExecutorOptions = {
119
119
  searchAdapter?: SearchAdapter;
120
120
  entityName: string; // required — the aggregateType marker on every event
121
121
  entityCache?: EntityCache;
122
- /** Override ENCRYPTION_KEY for entity fields marked `encrypted: true`. */
123
- encryption?: EncryptionProvider;
122
+ /** Override the boot-injected cipher for fields marked `encrypted: true`. */
123
+ encryption?: EnvelopeCipher;
124
124
  };
125
125
 
126
126
  // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
@@ -307,24 +307,28 @@ export function createEventStoreExecutor(
307
307
  const encryptedFields = collectEncryptedFieldNames(entity);
308
308
  const hasEncryptedFields = encryptedFields.size > 0;
309
309
 
310
- function encryptionProvider(): EncryptionProvider {
310
+ function fieldCipher(): EnvelopeCipher {
311
311
  if (options.encryption) return options.encryption;
312
312
  return resolveEntityFieldEncryption();
313
313
  }
314
314
 
315
- function encryptForStorage(
315
+ // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
316
+ // MasterKeyProvider. Callers MUST await — a missed await here writes
317
+ // "[object Promise]" into the projection, which the Promise return
318
+ // types turn into a compile error at every call site.
319
+ async function encryptForStorage(
316
320
  row: Record<string, unknown>,
317
321
  onlyKeys?: Iterable<string>,
318
- ): Record<string, unknown> {
322
+ ): Promise<Record<string, unknown>> {
319
323
  if (!hasEncryptedFields) return row;
320
- return encryptEntityFieldValues(row, encryptedFields, encryptionProvider(), {
324
+ return encryptEntityFieldValues(row, encryptedFields, fieldCipher(), {
321
325
  ...(onlyKeys !== undefined && { onlyKeys }),
322
326
  });
323
327
  }
324
328
 
325
- function decryptForRead(row: Record<string, unknown>): Record<string, unknown> {
329
+ async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
326
330
  if (!hasEncryptedFields) return row;
327
- return decryptEntityFieldValues(row, encryptedFields, encryptionProvider());
331
+ return decryptEntityFieldValues(row, encryptedFields, fieldCipher());
328
332
  }
329
333
 
330
334
  function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
@@ -356,7 +360,7 @@ export function createEventStoreExecutor(
356
360
  async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
357
361
  const row = await db.fetchOne(table, idFilter(id));
358
362
  if (!row) return null;
359
- return decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
363
+ return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
360
364
  }
361
365
 
362
366
  // Archive guard for the CRUD write paths. Archived streams are read-only —
@@ -465,7 +469,7 @@ export function createEventStoreExecutor(
465
469
  // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
466
470
  // dieselbe Pipeline. Caller schickt combined API-Form, Framework
467
471
  // speichert flat DB-Form. Siehe db/compound-types.ts.
468
- const flatData = encryptForStorage(flattenCompoundTypes(data, entity));
472
+ const flatData = await encryptForStorage(flattenCompoundTypes(data, entity));
469
473
 
470
474
  // 1. Append event (same TX as the projection write — both must succeed
471
475
  // or both roll back; the dispatcher wraps both in one transaction).
@@ -541,7 +545,9 @@ export function createEventStoreExecutor(
541
545
  const row = result.row;
542
546
  // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
543
547
  // Compound-Types in einem Pass.
544
- const projection = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
548
+ const projection = await decryptForRead(
549
+ rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
550
+ );
545
551
 
546
552
  if (entityCache && entityName) {
547
553
  await entityCache.del(user.tenantId, entityName, aggregateId);
@@ -644,7 +650,7 @@ export function createEventStoreExecutor(
644
650
 
645
651
  try {
646
652
  // Compound-Types Auto-Convert (alle in einem Pass).
647
- const flatChanges = encryptForStorage(
653
+ const flatChanges = await encryptForStorage(
648
654
  flattenCompoundTypes(payload.changes, entity),
649
655
  Object.keys(payload.changes),
650
656
  );
@@ -667,7 +673,7 @@ export function createEventStoreExecutor(
667
673
  type: entityEventName(entityName, "updated"),
668
674
  payload: {
669
675
  changes: stripSensitive(flatChanges),
670
- previous: stripSensitive(encryptForStorage(previous)),
676
+ previous: stripSensitive(await encryptForStorage(previous)),
671
677
  },
672
678
  metadata: buildEventMetadata(user),
673
679
  });
@@ -695,7 +701,7 @@ export function createEventStoreExecutor(
695
701
  return writeFailure(new InternalError({ message: "projection update returned no row" }));
696
702
  }
697
703
  const row = result.row;
698
- const data = decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
704
+ const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
699
705
 
700
706
  if (entityCache && entityName) {
701
707
  await entityCache.del(user.tenantId, entityName, payload.id);
@@ -777,7 +783,7 @@ export function createEventStoreExecutor(
777
783
  tenantId: streamTenantFor(user),
778
784
  expectedVersion: currentVersion,
779
785
  type: entityEventName(entityName, "deleted"),
780
- payload: { previous: stripSensitive(encryptForStorage(existing)) },
786
+ payload: { previous: stripSensitive(await encryptForStorage(existing)) },
781
787
  metadata: buildEventMetadata(user),
782
788
  });
783
789
 
@@ -810,7 +816,7 @@ export function createEventStoreExecutor(
810
816
  async forget(payload, user, db) {
811
817
  const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
812
818
  if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
813
- const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
819
+ const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
814
820
 
815
821
  if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
816
822
  return writeFailure(
@@ -937,7 +943,7 @@ export function createEventStoreExecutor(
937
943
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
938
944
  // same as every other executor method — `data`/`restored` are raw rows
939
945
  // (selectMany / applyEntityEvent), never decrypted before this point.
940
- const restoredHydrated = decryptForRead(
946
+ const restoredHydrated = await decryptForRead(
941
947
  rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
942
948
  );
943
949
 
@@ -948,7 +954,7 @@ export function createEventStoreExecutor(
948
954
  id: payload.id,
949
955
  data: restoredHydrated,
950
956
  changes: { isDeleted: false },
951
- previous: decryptForRead(data),
957
+ previous: await decryptForRead(data),
952
958
  isNew: false,
953
959
  entityName,
954
960
  event,
@@ -1082,8 +1088,10 @@ export function createEventStoreExecutor(
1082
1088
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
1083
1089
  // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
1084
1090
  const tableInfo = extractTableInfo(table);
1085
- const rows = rawRows.map((r) =>
1086
- coerceRow(decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1091
+ const rows = await Promise.all(
1092
+ rawRows.map(async (r) =>
1093
+ coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1094
+ ),
1087
1095
  );
1088
1096
 
1089
1097
  // list rows carry the READ-ROW version (display-only), never an optimistic-lock
@@ -1156,19 +1164,24 @@ export function createEventStoreExecutor(
1156
1164
  // Cached rows are stored re-encrypted (see the `set` below) so an
1157
1165
  // `encrypted` field's plaintext never sits in a second at-rest
1158
1166
  // store (Redis) the field-encryption feature doesn't cover.
1159
- return withStreamVersion(decryptForRead(cached));
1167
+ return withStreamVersion(await decryptForRead(cached));
1160
1168
  }
1161
1169
  }
1162
1170
 
1163
1171
  const rows = await loadWithOwnership(db, idWhere, ownership);
1164
1172
  const raw = rows[0];
1165
1173
  if (!raw) return null;
1166
- const row = decryptForRead(rehydrateCompoundTypes(raw, entity));
1174
+ const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
1167
1175
  const rowInfo = extractTableInfo(table);
1168
1176
  const coerced = coerceRow(row, rowInfo);
1169
1177
 
1170
1178
  if (entityCache && entityName) {
1171
- await entityCache.set(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
1179
+ await entityCache.set(
1180
+ user.tenantId,
1181
+ entityName,
1182
+ payload.id,
1183
+ await encryptForStorage(coerced),
1184
+ );
1172
1185
  }
1173
1186
 
1174
1187
  return withStreamVersion(coerced);
package/src/db/index.ts CHANGED
@@ -38,8 +38,11 @@ export type { EncryptionProvider } from "./encryption";
38
38
  export { createEncryptionProvider } from "./encryption";
39
39
  export {
40
40
  collectEncryptedFieldNames,
41
+ configuredEntityFieldEncryption,
42
+ configureEntityFieldEncryption,
41
43
  decryptEntityFieldValues,
42
44
  encryptEntityFieldValues,
45
+ resetEntityFieldEncryptionCacheForTests,
43
46
  } from "./entity-field-encryption";
44
47
  export type {
45
48
  BuildEntityTableMetaOptions,
@@ -106,8 +106,10 @@ describe("boot-validator", () => {
106
106
  expect(() => validateBoot(features)).toThrow(/token.*cannot be both encrypted and sortable/i);
107
107
  });
108
108
 
109
- test("allows encrypted field when ENCRYPTION_KEY is set", () => {
110
- process.env["ENCRYPTION_KEY"] = "test-key";
109
+ test("allows encrypted field when a master key keyring is available", () => {
110
+ process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from(
111
+ "0123456789abcdef0123456789abcdef",
112
+ ).toString("base64");
111
113
  try {
112
114
  const features = [
113
115
  defineFeature("a", (r) => {
@@ -124,12 +126,34 @@ describe("boot-validator", () => {
124
126
  ];
125
127
  expect(() => validateBoot(features)).not.toThrow();
126
128
  } finally {
127
- delete process.env["ENCRYPTION_KEY"];
129
+ delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
130
+ }
131
+ });
132
+
133
+ test("throws at boot when the master key is malformed (not 32 bytes)", () => {
134
+ process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = Buffer.from("too-short").toString("base64");
135
+ try {
136
+ const features = [
137
+ defineFeature("a", (r) => {
138
+ r.entity(
139
+ "secret",
140
+ createEntity({
141
+ table: "Secrets",
142
+ fields: {
143
+ apiKey: { type: "text", encrypted: true },
144
+ },
145
+ }),
146
+ );
147
+ }),
148
+ ];
149
+ expect(() => validateBoot(features)).toThrow(/master key/i);
150
+ } finally {
151
+ delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
128
152
  }
129
153
  });
130
154
 
131
- test("throws when encrypted fields exist but ENCRYPTION_KEY not set", () => {
132
- delete process.env["ENCRYPTION_KEY"];
155
+ test("throws when encrypted fields exist but no master key is available", () => {
156
+ delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
133
157
  const features = [
134
158
  defineFeature("a", (r) => {
135
159
  r.entity(
@@ -143,16 +167,16 @@ describe("boot-validator", () => {
143
167
  );
144
168
  }),
145
169
  ];
146
- expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
170
+ expect(() => validateBoot(features)).toThrow(/master key/i);
147
171
  });
148
172
 
149
- test("throws when longText encrypted field exists but ENCRYPTION_KEY not set", () => {
173
+ test("throws when longText encrypted field exists but no master key is available", () => {
150
174
  // Drift-pin Sprint-5b-vorab-Audit Issue 1: validateEncryptedFields
151
175
  // hatte `if (field.type !== "text") continue;` und ignorierte
152
176
  // longText-encrypted-fields silently — ENCRYPTION_KEY-check wurde
153
177
  // nie getriggert, encryption silent broken. Jetzt: beide string-
154
178
  // typed fields werden gechecked.
155
- delete process.env["ENCRYPTION_KEY"];
179
+ delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
156
180
  const features = [
157
181
  defineFeature("a", (r) => {
158
182
  r.entity(
@@ -166,7 +190,7 @@ describe("boot-validator", () => {
166
190
  );
167
191
  }),
168
192
  ];
169
- expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
193
+ expect(() => validateBoot(features)).toThrow(/master key/i);
170
194
  });
171
195
 
172
196
  test("rejects encrypted text field that is also filterable", () => {
@@ -1,3 +1,4 @@
1
+ import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
1
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
2
3
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
3
4
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
@@ -179,8 +180,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
179
180
  validateGdprStoragePersistence(features);
180
181
  validateGdprHookCompleteness(features);
181
182
 
182
- if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
183
- throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
183
+ if (hasEncryptedFields) {
184
+ // Availability check, not env-presence: eagerly building the keyring
185
+ // catches malformed keys (wrong length, bad base64) at boot instead of
186
+ // on the first encrypted read in prod. An injected cipher (test seam,
187
+ // custom KMS provider) satisfies the requirement without env keys.
188
+ validateEntityFieldEncryptionAvailable();
184
189
  }
185
190
 
186
191
  if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
@@ -235,8 +235,9 @@ type SharedContextFields = {
235
235
  // Encryption round-trip partner for the config feature. Separate from
236
236
  // configResolver so the read-only resolver contract stays clean — the
237
237
  // set handler needs to encrypt on write, the resolver needs to decrypt
238
- // on read, and both reach for the same provider. Wired via extraContext.
239
- readonly configEncryption?: import("../../db").EncryptionProvider;
238
+ // on read, and both reach for the same cipher. Wired via extraContext;
239
+ // run{Prod,Dev}App build it from the secrets master key automatically.
240
+ readonly configEncryption?: import("../../secrets").EnvelopeCipher;
240
241
  // Rate-limit resolver. Wired by the framework when the `rate-limiting`
241
242
  // feature is loaded — pipeline reads handler.rateLimit and calls
242
243
  // .enforce() on this resolver before access-check. Absent when the
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createEncryptionProvider } from "../../db/encryption";
4
+ import { createEnvMasterKeyProvider } from "../env-master-key-provider";
5
+ import { createEnvelopeCipher } from "../envelope-cipher";
6
+ import { isStoredEnvelope } from "../stored-envelope";
7
+
8
+ function makeProvider(currentVersion = 1) {
9
+ return createEnvMasterKeyProvider({
10
+ env: {
11
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: String(currentVersion),
12
+ [`KUMIKO_SECRETS_MASTER_KEY_V${currentVersion}`]: randomBytes(32).toString("base64"),
13
+ },
14
+ });
15
+ }
16
+
17
+ describe("envelope-cipher — encrypt/decrypt", () => {
18
+ test("round-trips plaintext through the JSON envelope format", async () => {
19
+ const cipher = createEnvelopeCipher(makeProvider());
20
+ const stored = await cipher.encrypt("s3cret-smtp-pass");
21
+
22
+ expect(stored.startsWith("{")).toBe(true);
23
+ const parsed: unknown = JSON.parse(stored);
24
+ expect(isStoredEnvelope(parsed)).toBe(true);
25
+
26
+ expect(await cipher.decrypt(stored)).toBe("s3cret-smtp-pass");
27
+ });
28
+
29
+ test("stored value carries the kekVersion for later rotation", async () => {
30
+ const cipher = createEnvelopeCipher(makeProvider(7));
31
+ const stored = await cipher.encrypt("x");
32
+ expect((JSON.parse(stored) as { kekVersion: number }).kekVersion).toBe(7);
33
+ });
34
+
35
+ test("tampered ciphertext fails GCM authentication", async () => {
36
+ const cipher = createEnvelopeCipher(makeProvider());
37
+ const stored = JSON.parse(await cipher.encrypt("payload")) as Record<string, unknown>;
38
+ const cipherBytes = Buffer.from(stored["ciphertext"] as string, "base64");
39
+ cipherBytes[0] = (cipherBytes[0] ?? 0) ^ 0xff;
40
+ const tampered = JSON.stringify({ ...stored, ciphertext: cipherBytes.toString("base64") });
41
+ await expect(cipher.decrypt(tampered)).rejects.toThrow();
42
+ });
43
+ });
44
+
45
+ describe("envelope-cipher — legacy fallback", () => {
46
+ test("decrypts legacy single-key values when the legacy provider is configured", async () => {
47
+ const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
48
+ const legacyStored = legacy.encrypt("pre-envelope value");
49
+ // legacy wire format is base64 — never starts with "{"
50
+ expect(legacyStored.startsWith("{")).toBe(false);
51
+
52
+ const cipher = createEnvelopeCipher(makeProvider(), { legacy });
53
+ expect(await cipher.decrypt(legacyStored)).toBe("pre-envelope value");
54
+ });
55
+
56
+ test("throws with a remediation message when a legacy value has no legacy key", async () => {
57
+ const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
58
+ const legacyStored = legacy.encrypt("orphaned");
59
+
60
+ const cipher = createEnvelopeCipher(makeProvider());
61
+ await expect(cipher.decrypt(legacyStored)).rejects.toThrow(/legacy/);
62
+ });
63
+
64
+ test("never encrypts into the legacy format even when a legacy key is present", async () => {
65
+ const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
66
+ const cipher = createEnvelopeCipher(makeProvider(), { legacy });
67
+ const stored = await cipher.encrypt("always-envelope");
68
+ expect(stored.startsWith("{")).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("envelope-cipher — malformed input", () => {
73
+ test("rejects invalid JSON that looks like an envelope", async () => {
74
+ const cipher = createEnvelopeCipher(makeProvider());
75
+ await expect(cipher.decrypt("{garbage")).rejects.toThrow(/not valid JSON/);
76
+ });
77
+
78
+ test("rejects JSON that is not a StoredEnvelope", async () => {
79
+ const cipher = createEnvelopeCipher(makeProvider());
80
+ await expect(cipher.decrypt('{"foo":"bar"}')).rejects.toThrow(/not a StoredEnvelope/);
81
+ });
82
+
83
+ test("empty string routes to the legacy branch and throws without a legacy key", async () => {
84
+ const cipher = createEnvelopeCipher(makeProvider());
85
+ await expect(cipher.decrypt("")).rejects.toThrow(/legacy/);
86
+ });
87
+ });
@@ -9,7 +9,7 @@
9
9
  // - maxEntries (default 1000): LRU eviction kicks in on insert when full.
10
10
 
11
11
  import { createHash } from "node:crypto";
12
- import type { MasterKeyProvider } from "./types";
12
+ import type { KeyScope, MasterKeyProvider } from "./types";
13
13
 
14
14
  const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
15
15
  const DEFAULT_MAX_ENTRIES = 1000;
@@ -25,8 +25,16 @@ export type DekCacheOptions = {
25
25
 
26
26
  export type DekCache = {
27
27
  // Unwrap via the provider, caching the result. Second call within TTL
28
- // returns the cached DEK without hitting the provider.
29
- unwrapDek(encryptedDek: Buffer, kekVersion: number, provider: MasterKeyProvider): Promise<Buffer>;
28
+ // returns the cached DEK without hitting the provider. The cache key is
29
+ // (encryptedDek, kekVersion) scope is only forwarded to the provider;
30
+ // encryptedDek bytes are unique per value, so scoped providers can't
31
+ // collide on the key either.
32
+ unwrapDek(
33
+ encryptedDek: Buffer,
34
+ kekVersion: number,
35
+ provider: MasterKeyProvider,
36
+ scope?: KeyScope,
37
+ ): Promise<Buffer>;
30
38
 
31
39
  // Drop every entry. Call after KEK rotation so old cached DEKs (still
32
40
  // valid, but referencing the old kekVersion) don't serve reads that
@@ -37,6 +45,19 @@ export type DekCache = {
37
45
  size(): number;
38
46
  };
39
47
 
48
+ // Wrap a provider so its unwrapDek goes through the cache. Callers keep the
49
+ // full MasterKeyProvider contract without knowing about caching —
50
+ // decryptValue handles crypto, the cache handles cost.
51
+ export function withDekCache(provider: MasterKeyProvider, cache: DekCache): MasterKeyProvider {
52
+ return {
53
+ wrapDek: (dek, scope) => provider.wrapDek(dek, scope),
54
+ unwrapDek: (encryptedDek, version, scope) =>
55
+ cache.unwrapDek(encryptedDek, version, provider, scope),
56
+ currentVersion: () => provider.currentVersion(),
57
+ isAvailable: () => provider.isAvailable(),
58
+ };
59
+ }
60
+
40
61
  export function createDekCache(opts: DekCacheOptions = {}): DekCache {
41
62
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
42
63
  const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
@@ -74,7 +95,7 @@ export function createDekCache(opts: DekCacheOptions = {}): DekCache {
74
95
  }
75
96
 
76
97
  return {
77
- async unwrapDek(encryptedDek, kekVersion, provider) {
98
+ async unwrapDek(encryptedDek, kekVersion, provider, scope) {
78
99
  const key = cacheKey(encryptedDek, kekVersion);
79
100
  const hit = entries.get(key);
80
101
  if (hit && hit.expiresAt > now()) {
@@ -92,7 +113,7 @@ export function createDekCache(opts: DekCacheOptions = {}): DekCache {
92
113
  entries.delete(key);
93
114
  }
94
115
 
95
- const dek = await provider.unwrapDek(encryptedDek, kekVersion);
116
+ const dek = await provider.unwrapDek(encryptedDek, kekVersion, scope);
96
117
  evictOldestIfFull();
97
118
  // Store a copy — caller can zero its own buffer after use.
98
119
  entries.set(key, { dek: Buffer.from(dek), expiresAt: now() + ttlMs });
@@ -0,0 +1,81 @@
1
+ // String-in/string-out envelope encryption for TEXT-column stores (config
2
+ // values, entity fields). The stored string is JSON of StoredEnvelope; the
3
+ // kekVersion inside makes every value rotatable via the MasterKeyProvider
4
+ // keyring — unlike the legacy createEncryptionProvider format (raw
5
+ // base64(iv+tag+ct), no key id), which this cipher still DECRYPTS through
6
+ // the optional legacy provider so pre-envelope rows stay readable until a
7
+ // re-encrypt job has migrated them.
8
+
9
+ import type { EncryptionProvider } from "../db/encryption";
10
+ import { InternalError } from "../errors/classes";
11
+ import type { DekCache } from "./dek-cache";
12
+ import { createDekCache, withDekCache } from "./dek-cache";
13
+ import { decryptValue, encryptValue } from "./envelope";
14
+ import { decodeStoredEnvelope, encodeStoredEnvelope, isStoredEnvelope } from "./stored-envelope";
15
+ import type { KeyScope, MasterKeyProvider } from "./types";
16
+
17
+ export type EnvelopeCipherOptions = {
18
+ // Decrypt-only fallback for legacy createEncryptionProvider ciphertexts
19
+ // (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY era). Never used for encrypt.
20
+ readonly legacy?: EncryptionProvider;
21
+ // Shared DEK cache — pass the app-wide instance so config/entity reads
22
+ // amortise KEK unwraps together with ctx.secrets.
23
+ readonly dekCache?: DekCache;
24
+ };
25
+
26
+ export type EnvelopeCipher = {
27
+ encrypt(plaintext: string, scope?: KeyScope): Promise<string>;
28
+ decrypt(stored: string, scope?: KeyScope): Promise<string>;
29
+ };
30
+
31
+ // Format detection: envelope values are JSON objects, so they start with
32
+ // "{" — a character the base64 alphabet of the legacy format can never
33
+ // produce. No version byte or prefix marker needed.
34
+ function isEnvelopeFormat(stored: string): boolean {
35
+ return stored.startsWith("{");
36
+ }
37
+
38
+ export function createEnvelopeCipher(
39
+ provider: MasterKeyProvider,
40
+ opts: EnvelopeCipherOptions = {},
41
+ ): EnvelopeCipher {
42
+ const cached = withDekCache(provider, opts.dekCache ?? createDekCache());
43
+
44
+ return {
45
+ async encrypt(plaintext, scope) {
46
+ const envelope = await encryptValue(plaintext, provider, scope);
47
+ return JSON.stringify(encodeStoredEnvelope(envelope));
48
+ },
49
+
50
+ async decrypt(stored, scope) {
51
+ if (isEnvelopeFormat(stored)) {
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(stored);
55
+ } catch {
56
+ throw new InternalError({
57
+ message: "[envelope-cipher] stored value looks like an envelope but is not valid JSON",
58
+ i18nKey: "secrets.errors.envelope_malformed",
59
+ });
60
+ }
61
+ if (!isStoredEnvelope(parsed)) {
62
+ throw new InternalError({
63
+ message: "[envelope-cipher] stored JSON is not a StoredEnvelope",
64
+ i18nKey: "secrets.errors.envelope_malformed",
65
+ });
66
+ }
67
+ return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
68
+ }
69
+
70
+ if (!opts.legacy) {
71
+ throw new InternalError({
72
+ message:
73
+ "[envelope-cipher] value is in the legacy single-key format but no legacy key is configured — " +
74
+ "provision the legacy key (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY) or run the re-encrypt job first",
75
+ i18nKey: "secrets.errors.legacy_key_missing",
76
+ });
77
+ }
78
+ return opts.legacy.decrypt(stored);
79
+ },
80
+ };
81
+ }
@@ -3,7 +3,7 @@
3
3
  // carries everything needed to decrypt later.
4
4
 
5
5
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
6
- import type { Envelope, MasterKeyProvider } from "./types";
6
+ import type { Envelope, KeyScope, MasterKeyProvider } from "./types";
7
7
 
8
8
  const ALGORITHM = "aes-256-gcm";
9
9
  const DEK_LENGTH = 32; // AES-256
@@ -14,6 +14,7 @@ const IV_LENGTH = 12; // GCM standard nonce length
14
14
  export async function encryptValue(
15
15
  plaintext: string,
16
16
  provider: MasterKeyProvider,
17
+ scope?: KeyScope,
17
18
  ): Promise<Envelope> {
18
19
  // Fresh DEK per value. Reusing DEKs across rows would break forward
19
20
  // secrecy (one compromised ciphertext-IV pair leaks information about
@@ -26,7 +27,7 @@ export async function encryptValue(
26
27
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
27
28
  const authTag = cipher.getAuthTag();
28
29
 
29
- const { encryptedDek, kekVersion } = await provider.wrapDek(dek);
30
+ const { encryptedDek, kekVersion } = await provider.wrapDek(dek, scope);
30
31
  return { ciphertext, iv, authTag, encryptedDek, kekVersion };
31
32
  } finally {
32
33
  // Zero the DEK on best effort regardless of success — if provider.wrapDek
@@ -42,8 +43,9 @@ export async function encryptValue(
42
43
  export async function decryptValue(
43
44
  envelope: Envelope,
44
45
  provider: MasterKeyProvider,
46
+ scope?: KeyScope,
45
47
  ): Promise<string> {
46
- const dek = await provider.unwrapDek(envelope.encryptedDek, envelope.kekVersion);
48
+ const dek = await provider.unwrapDek(envelope.encryptedDek, envelope.kekVersion, scope);
47
49
  try {
48
50
  const decipher = createDecipheriv(ALGORITHM, dek, envelope.iv);
49
51
  decipher.setAuthTag(envelope.authTag);
@@ -1,17 +1,29 @@
1
- export { createDekCache, type DekCache, type DekCacheOptions } from "./dek-cache";
1
+ export { createDekCache, type DekCache, type DekCacheOptions, withDekCache } from "./dek-cache";
2
2
  export {
3
3
  createEnvMasterKeyProvider,
4
4
  type EnvMasterKeyProviderOptions,
5
5
  type Keyring,
6
6
  } from "./env-master-key-provider";
7
7
  export { decryptValue, encryptValue } from "./envelope";
8
+ export {
9
+ createEnvelopeCipher,
10
+ type EnvelopeCipher,
11
+ type EnvelopeCipherOptions,
12
+ } from "./envelope-cipher";
8
13
  export { assertNoSecretLeak } from "./leak-guard";
9
14
  export { rewrapDek } from "./rotation";
15
+ export {
16
+ decodeStoredEnvelope,
17
+ encodeStoredEnvelope,
18
+ isStoredEnvelope,
19
+ type StoredEnvelope,
20
+ } from "./stored-envelope";
10
21
  export {
11
22
  type ContainsSecret,
12
23
  createSecret,
13
24
  type Envelope,
14
25
  isSecret,
26
+ type KeyScope,
15
27
  type MasterKeyProvider,
16
28
  type Secret,
17
29
  type SecretAuditContext,
@@ -0,0 +1,46 @@
1
+ // Canonical at-rest form of an Envelope: every Buffer as base64, ready for
2
+ // jsonb columns (secrets feature) or JSON-stringified TEXT columns
3
+ // (EnvelopeCipher for config values / entity fields). One wire shape for
4
+ // every envelope-encrypted store in the framework.
5
+
6
+ import type { Envelope } from "./types";
7
+
8
+ export type StoredEnvelope = {
9
+ readonly ciphertext: string; // base64
10
+ readonly iv: string; // base64
11
+ readonly authTag: string; // base64
12
+ readonly encryptedDek: string; // base64
13
+ readonly kekVersion: number;
14
+ };
15
+
16
+ export function encodeStoredEnvelope(envelope: Envelope): StoredEnvelope {
17
+ return {
18
+ ciphertext: envelope.ciphertext.toString("base64"),
19
+ iv: envelope.iv.toString("base64"),
20
+ authTag: envelope.authTag.toString("base64"),
21
+ encryptedDek: envelope.encryptedDek.toString("base64"),
22
+ kekVersion: envelope.kekVersion,
23
+ };
24
+ }
25
+
26
+ export function decodeStoredEnvelope(stored: StoredEnvelope): Envelope {
27
+ return {
28
+ ciphertext: Buffer.from(stored.ciphertext, "base64"),
29
+ iv: Buffer.from(stored.iv, "base64"),
30
+ authTag: Buffer.from(stored.authTag, "base64"),
31
+ encryptedDek: Buffer.from(stored.encryptedDek, "base64"),
32
+ kekVersion: stored.kekVersion,
33
+ };
34
+ }
35
+
36
+ export function isStoredEnvelope(value: unknown): value is StoredEnvelope {
37
+ if (!value || typeof value !== "object") return false;
38
+ const v = value as Record<string, unknown>; // @cast-boundary parse-boundary after typeof check
39
+ return (
40
+ typeof v["ciphertext"] === "string" &&
41
+ typeof v["iv"] === "string" &&
42
+ typeof v["authTag"] === "string" &&
43
+ typeof v["encryptedDek"] === "string" &&
44
+ typeof v["kekVersion"] === "number"
45
+ );
46
+ }
@@ -144,6 +144,14 @@ export type Envelope = {
144
144
  readonly kekVersion: number;
145
145
  };
146
146
 
147
+ // BYOK hook: callers pass the tenant a value belongs to; a per-tenant-KMS
148
+ // provider keys its wrap/unwrap on it. EnvMasterKeyProvider (app-wide
149
+ // keyring) ignores it — the param exists so the contract doesn't have to
150
+ // break when a tenant-scoped provider ships.
151
+ export type KeyScope = {
152
+ readonly tenantId?: TenantId;
153
+ };
154
+
147
155
  // The contract a KEK backend must fulfil. The framework sees only this
148
156
  // interface; concrete implementations live in separate packages
149
157
  // (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
@@ -152,12 +160,12 @@ export interface MasterKeyProvider {
152
160
  // Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
153
161
  // KEK version used — the version ends up in the Envelope so decryption
154
162
  // later knows which KEK to ask for.
155
- wrapDek(dek: Buffer): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
163
+ wrapDek(dek: Buffer, scope?: KeyScope): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
156
164
 
157
165
  // Unwrap a previously-wrapped DEK. During rotation the provider must
158
166
  // accept older kekVersion values (2-version window minimum), otherwise
159
167
  // old rows become unreadable.
160
- unwrapDek(encryptedDek: Buffer, kekVersion: number): Promise<Buffer>;
168
+ unwrapDek(encryptedDek: Buffer, kekVersion: number, scope?: KeyScope): Promise<Buffer>;
161
169
 
162
170
  // Which KEK version new wraps use. Rotation flips this to a new value
163
171
  // and older-version reads continue to work until rows are re-wrapped.
@@ -25,6 +25,8 @@ export { createLateBoundHolder, type LateBoundHolder } from "./late-bound";
25
25
  export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper";
26
26
  export {
27
27
  createMutableMasterKeyProvider,
28
+ createTestEnvelopeCipher,
29
+ createTestMasterKeyProvider,
28
30
  type MutableMasterKeyProvider,
29
31
  } from "./mutable-master-key-provider";
30
32
  export {
@@ -6,7 +6,14 @@
6
6
  // tests that want to exercise pre- and post-rotation behaviour in a
7
7
  // single suite.
8
8
 
9
- import type { MasterKeyProvider } from "../secrets";
9
+ import { randomBytes } from "node:crypto";
10
+ import {
11
+ createEnvelopeCipher,
12
+ createEnvMasterKeyProvider,
13
+ type EnvelopeCipher,
14
+ type EnvelopeCipherOptions,
15
+ type MasterKeyProvider,
16
+ } from "../secrets";
10
17
 
11
18
  export type MutableMasterKeyProvider = MasterKeyProvider & {
12
19
  // Replace the backing provider. All future wrapDek/unwrapDek/currentVersion
@@ -20,8 +27,8 @@ export function createMutableMasterKeyProvider(
20
27
  ): MutableMasterKeyProvider {
21
28
  let current = initial;
22
29
  return {
23
- wrapDek: (dek) => current.wrapDek(dek),
24
- unwrapDek: (e, v) => current.unwrapDek(e, v),
30
+ wrapDek: (dek, scope) => current.wrapDek(dek, scope),
31
+ unwrapDek: (e, v, scope) => current.unwrapDek(e, v, scope),
25
32
  currentVersion: () => current.currentVersion(),
26
33
  isAvailable: () => current.isAvailable(),
27
34
  replace: (next) => {
@@ -29,3 +36,22 @@ export function createMutableMasterKeyProvider(
29
36
  },
30
37
  };
31
38
  }
39
+
40
+ // Single-version env provider for tests — the shape every integration test
41
+ // needs to exercise encrypted config keys / entity fields without caring
42
+ // about keyring mechanics. Pass a fixed key to share it across stacks.
43
+ export function createTestMasterKeyProvider(keyBase64?: string): MasterKeyProvider {
44
+ return createEnvMasterKeyProvider({
45
+ env: {
46
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
47
+ KUMIKO_SECRETS_MASTER_KEY_V1: keyBase64 ?? randomBytes(32).toString("base64"),
48
+ },
49
+ });
50
+ }
51
+
52
+ export function createTestEnvelopeCipher(
53
+ keyBase64?: string,
54
+ opts?: EnvelopeCipherOptions,
55
+ ): EnvelopeCipher {
56
+ return createEnvelopeCipher(createTestMasterKeyProvider(keyBase64), opts ?? {});
57
+ }