@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 +2 -2
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/event-store-executor.integration.test.ts +3 -4
- package/src/db/config-seed.ts +9 -9
- package/src/db/encryption.ts +7 -0
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/event-store-executor.ts +37 -24
- package/src/db/index.ts +3 -0
- package/src/engine/__tests__/boot-validator.test.ts +33 -9
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/types/handlers.ts +3 -2
- package/src/secrets/__tests__/envelope-cipher.test.ts +87 -0
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +81 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +10 -2
- package/src/testing/index.ts +2 -0
- package/src/testing/mutable-master-key-provider.ts +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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 =
|
|
55
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
526
|
+
const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
|
|
528
527
|
const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
|
|
529
528
|
entityName: "esExecEncrypted",
|
|
530
529
|
entityCache,
|
package/src/db/config-seed.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
if (keyDef.encrypted && !
|
|
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
|
|
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 &&
|
|
81
|
-
value =
|
|
80
|
+
if (keyDef.encrypted && cipher) {
|
|
81
|
+
value = await cipher.encrypt(value, { tenantId });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const payload: Record<string, unknown> = {
|
package/src/db/encryption.ts
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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] =
|
|
76
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
60
77
|
}
|
|
61
78
|
return out;
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
encryption?:
|
|
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
|
|
310
|
+
function fieldCipher(): EnvelopeCipher {
|
|
311
311
|
if (options.encryption) return options.encryption;
|
|
312
312
|
return resolveEntityFieldEncryption();
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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 =
|
|
1086
|
-
|
|
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(
|
|
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
|
|
110
|
-
process.env["
|
|
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["
|
|
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
|
|
132
|
-
delete process.env["
|
|
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(/
|
|
170
|
+
expect(() => validateBoot(features)).toThrow(/master key/i);
|
|
147
171
|
});
|
|
148
172
|
|
|
149
|
-
test("throws when longText encrypted field exists but
|
|
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["
|
|
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(/
|
|
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
|
|
183
|
-
|
|
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
|
|
239
|
-
|
|
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
|
+
});
|
package/src/secrets/dek-cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/secrets/envelope.ts
CHANGED
|
@@ -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);
|
package/src/secrets/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/secrets/types.ts
CHANGED
|
@@ -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.
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|