@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/package.json +2 -2
- package/src/api/__tests__/batch.integration.test.ts +4 -1
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- 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/__tests__/tenant-db-where-merge.test.ts +4 -1
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- 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/db/tenant-db.ts +14 -9
- 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/observability/__tests__/observability.integration.test.ts +4 -1
- 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/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,
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -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:
|
|
59
|
+
table: WritableTable,
|
|
49
60
|
values: Record<string, unknown>,
|
|
50
61
|
): Promise<T | undefined>;
|
|
51
62
|
updateMany<T = Record<string, unknown>>(
|
|
52
|
-
table:
|
|
63
|
+
table: WritableTable,
|
|
53
64
|
set: Record<string, unknown>,
|
|
54
65
|
where: WhereObject,
|
|
55
66
|
): Promise<readonly T[]>;
|
|
56
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
+
});
|
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
|
+
}
|