@cosmicdrift/kumiko-framework 0.109.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/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,
@@ -12,10 +12,21 @@ import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
12
12
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
13
13
  import type { DbRunner } from "./connection";
14
14
  import type { TableColumns } from "./dialect";
15
+ import type { EntityTableMeta } from "./entity-table-meta";
16
+ import type { NotExecutorOnly } from "./table-builder";
15
17
 
16
18
  // biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
17
19
  type Table = TableColumns<any>;
18
20
 
21
+ // Method-form writes reject the executor-only brand exactly like the free-function
22
+ // helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
23
+ // directly — free-function OR method-form — drifts the row past its event stream and
24
+ // a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
25
+ // metas are not projections → writable); `& NotExecutorOnly` strips only branded
26
+ // EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
27
+ // the plain `Table` param.
28
+ type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
29
+
19
30
  /**
20
31
  * TenantDb scope modes:
21
32
  *
@@ -45,21 +56,15 @@ export type TenantDb = {
45
56
  ): Promise<readonly T[]>;
46
57
  fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
47
58
  insertOne<T = Record<string, unknown>>(
48
- table: Table,
59
+ table: WritableTable,
49
60
  values: Record<string, unknown>,
50
61
  ): Promise<T | undefined>;
51
62
  updateMany<T = Record<string, unknown>>(
52
- table: Table,
63
+ table: WritableTable,
53
64
  set: Record<string, unknown>,
54
65
  where: WhereObject,
55
66
  ): Promise<readonly T[]>;
56
- // Method-form writes (ctx.db.insertOne/updateMany/deleteMany) keep the erased
57
- // Table param: the executor-only brand is enforced on the free-function
58
- // helpers (the reflexive path all production writes take), while method-form
59
- // on a branded table is covered by the guard-direct-entity-writes AST guard
60
- // (P5) — branding it here would force every push+write test into casts for
61
- // zero production benefit (no prod code writes a projection via method-form).
62
- deleteMany(table: Table, where: WhereObject): Promise<void>;
67
+ deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
63
68
  };
64
69
 
65
70
  // @cast-boundary tenant-db-row
@@ -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
@@ -1,5 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
+ import type { TableColumns } from "../../db/dialect";
3
4
  import { buildEntityTable } from "../../db/table-builder";
4
5
  import { createRegistry, defineFeature } from "../../engine";
5
6
  import type { AppContext, SaveContext } from "../../engine/types";
@@ -65,7 +66,9 @@ const todoEntity = {
65
66
 
66
67
  let postSaveInvocations = 0;
67
68
  const todoFeature = defineFeature("todo", (r) => {
68
- const todoTable = buildEntityTable("todo", todoEntity);
69
+ // Brand (#742) is compile-time-only; the unbranded TableColumns view keeps the
70
+ // method-form insert (and its observability span) working — runtime shape is identical.
71
+ const todoTable: TableColumns = buildEntityTable("todo", todoEntity);
69
72
  r.entity("todo", todoEntity);
70
73
 
71
74
  r.writeHandler(
@@ -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
+ }