@crewhaus/audit-encryption 0.1.1 → 0.1.3
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 +8 -13
- package/src/index.test.ts +667 -2
- package/src/index.ts +420 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/audit-encryption",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Envelope encryption for hash-chained audit records: per-tenant DEK wrapped by KEK from @crewhaus/secrets-manager (Section 39)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/audit-log": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/secrets-manager": "0.1.
|
|
15
|
+
"@crewhaus/audit-log": "0.1.3",
|
|
16
|
+
"@crewhaus/errors": "0.1.3",
|
|
17
|
+
"@crewhaus/secrets-manager": "0.1.3"
|
|
18
18
|
},
|
|
19
19
|
"license": "Apache-2.0",
|
|
20
20
|
"author": {
|
|
21
21
|
"name": "Max Meier",
|
|
22
|
-
"email": "max@
|
|
23
|
-
"url": "https://
|
|
22
|
+
"email": "max@crewhaus.ai",
|
|
23
|
+
"url": "https://crewhaus.ai"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
@@ -32,12 +32,7 @@
|
|
|
32
32
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
|
-
"access": "
|
|
35
|
+
"access": "public"
|
|
36
36
|
},
|
|
37
|
-
"files": [
|
|
38
|
-
"src",
|
|
39
|
-
"README.md",
|
|
40
|
-
"LICENSE",
|
|
41
|
-
"NOTICE"
|
|
42
|
-
]
|
|
37
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
43
38
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
2
6
|
import { createEnvVarBackend, createSecrets } from "@crewhaus/secrets-manager";
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
AuditEncryptionError,
|
|
9
|
+
type EncryptedRecord,
|
|
10
|
+
InMemoryDekStore,
|
|
11
|
+
_decryptBytesForTest,
|
|
12
|
+
_deriveKekKeyForTest,
|
|
13
|
+
_deriveKekKeyLegacyForTest,
|
|
14
|
+
_encryptBytesForTest,
|
|
15
|
+
createAuditEncryption,
|
|
16
|
+
createFileDekStore,
|
|
17
|
+
staticKekProvider,
|
|
18
|
+
} from "./index";
|
|
4
19
|
|
|
5
20
|
function setKek(name: string, value: string): void {
|
|
6
21
|
process.env[name] = value;
|
|
@@ -164,3 +179,653 @@ describe("DekStore plumbing", () => {
|
|
|
164
179
|
expect(out?.length).toBe(32);
|
|
165
180
|
});
|
|
166
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("#163 — salted, stretched KEK derivation (CWE-916)", () => {
|
|
184
|
+
test("records carry a persisted scrypt salt and decrypt using it", async () => {
|
|
185
|
+
const enc = await buildEncryption("passphrase-shaped-kek");
|
|
186
|
+
const record = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
187
|
+
// Salt is persisted on the record (16 bytes => 32 hex chars).
|
|
188
|
+
expect(record.kekSalt).toMatch(/^[a-f0-9]{32}$/);
|
|
189
|
+
// The wrapping key is NOT a bare SHA-256 of the KEK: tampering with
|
|
190
|
+
// the persisted salt must break unwrapping (proves the salt feeds
|
|
191
|
+
// the derivation rather than being decorative).
|
|
192
|
+
const wrongSalt = { ...record, kekSalt: "00".repeat(16) };
|
|
193
|
+
await expect(enc.decryptPayload(wrongSalt)).rejects.toThrow();
|
|
194
|
+
// With the genuine salt, the record round-trips.
|
|
195
|
+
expect(await enc.decryptPayload(record)).toEqual({ x: 1 });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("salt is fresh per record", async () => {
|
|
199
|
+
const enc = await buildEncryption();
|
|
200
|
+
const a = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
201
|
+
const b = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
202
|
+
expect(a.kekSalt).not.toBe(b.kekSalt);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("legacy unsalted-SHA-256 records still decrypt (back-compat)", async () => {
|
|
206
|
+
// Hand-build a record in the pre-migration format: DEK wrapped under a
|
|
207
|
+
// bare SHA-256 of the KEK, with no `kekSalt` field.
|
|
208
|
+
const kekValue = "legacy-kek-value-123456";
|
|
209
|
+
setKek("KEK_TEST", kekValue);
|
|
210
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
211
|
+
const store = new InMemoryDekStore();
|
|
212
|
+
const dek = randomBytes(32);
|
|
213
|
+
await store.set("tenant-legacy", dek);
|
|
214
|
+
const legacyKekKey = createHash("sha256").update(kekValue).digest();
|
|
215
|
+
|
|
216
|
+
const payloadIv = randomBytes(12);
|
|
217
|
+
const pc = createCipheriv("aes-256-gcm", dek, payloadIv);
|
|
218
|
+
const encryptedPayload = Buffer.concat([
|
|
219
|
+
pc.update(Buffer.from(JSON.stringify({ legacy: true }), "utf8")),
|
|
220
|
+
pc.final(),
|
|
221
|
+
]);
|
|
222
|
+
const payloadTag = pc.getAuthTag();
|
|
223
|
+
|
|
224
|
+
const dekIv = randomBytes(12);
|
|
225
|
+
const wc = createCipheriv("aes-256-gcm", legacyKekKey, dekIv);
|
|
226
|
+
const wrappedDek = Buffer.concat([wc.update(dek), wc.final()]);
|
|
227
|
+
const wrappedTag = wc.getAuthTag();
|
|
228
|
+
|
|
229
|
+
const legacyRecord: EncryptedRecord = {
|
|
230
|
+
tenantId: "tenant-legacy",
|
|
231
|
+
kekRef: "kek:KEK_TEST:v1", // the ref createAuditEncryption assigns at init
|
|
232
|
+
dekRef: "dek:tenant-legacy:v1",
|
|
233
|
+
// no kekSalt — the migration's tell-tale that legacy derivation applies
|
|
234
|
+
iv: payloadIv.toString("hex"),
|
|
235
|
+
tag: payloadTag.toString("hex"),
|
|
236
|
+
encryptedPayload: encryptedPayload.toString("hex"),
|
|
237
|
+
wrappedDek: wrappedDek.toString("hex"),
|
|
238
|
+
wrappedDekIv: dekIv.toString("hex"),
|
|
239
|
+
wrappedDekTag: wrappedTag.toString("hex"),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
243
|
+
expect(await enc.decryptPayload(legacyRecord)).toEqual({ legacy: true });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("#164 — DEK versioning + rotation re-keys (CWE-323)", () => {
|
|
248
|
+
test("a record encrypted before rotateKek still decrypts after", async () => {
|
|
249
|
+
const store = new InMemoryDekStore();
|
|
250
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
251
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
252
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
253
|
+
|
|
254
|
+
const before = await enc.encryptPayload({ era: "v1" }, "tenant-a");
|
|
255
|
+
expect(before.kekRef).toBe("kek:KEK_TEST:v1");
|
|
256
|
+
|
|
257
|
+
await enc.rotateKek("kek-v2-secret-87654321", "kek:KEK_TEST:v2");
|
|
258
|
+
expect(enc.kekRef).toBe("kek:KEK_TEST:v2");
|
|
259
|
+
|
|
260
|
+
// Historical record (sealed under the now-superseded KEK) still decrypts.
|
|
261
|
+
expect(await enc.decryptPayload(before)).toEqual({ era: "v1" });
|
|
262
|
+
|
|
263
|
+
// And new writes use the new KEK + a freshly-minted DEK version.
|
|
264
|
+
const after = await enc.encryptPayload({ era: "v2" }, "tenant-a");
|
|
265
|
+
expect(after.kekRef).toBe("kek:KEK_TEST:v2");
|
|
266
|
+
expect(await enc.decryptPayload(after)).toEqual({ era: "v2" });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("rotateKek mints a fresh DEK version per tenant", async () => {
|
|
270
|
+
const store = new InMemoryDekStore();
|
|
271
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
272
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
273
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
274
|
+
|
|
275
|
+
const v1a = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
276
|
+
const v1b = await enc.encryptPayload({ x: 1 }, "tenant-b");
|
|
277
|
+
expect(v1a.dekRef).toBe("dek:tenant-a:v1");
|
|
278
|
+
expect(v1b.dekRef).toBe("dek:tenant-b:v1");
|
|
279
|
+
|
|
280
|
+
await enc.rotateKek("kek-v2-secret-87654321", "kek:KEK_TEST:v2");
|
|
281
|
+
|
|
282
|
+
const v2a = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
283
|
+
const v2b = await enc.encryptPayload({ x: 1 }, "tenant-b");
|
|
284
|
+
expect(v2a.dekRef).toBe("dek:tenant-a:v2");
|
|
285
|
+
expect(v2b.dekRef).toBe("dek:tenant-b:v2");
|
|
286
|
+
|
|
287
|
+
// The new DEK is genuinely different material (different wrapped DEK
|
|
288
|
+
// even accounting for the fresh salt).
|
|
289
|
+
expect(v2a.wrappedDek).not.toBe(v1a.wrappedDek);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("DEK rolls to a new version once the per-DEK record threshold is hit", async () => {
|
|
293
|
+
const store = new InMemoryDekStore();
|
|
294
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
295
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
296
|
+
const enc = await createAuditEncryption({
|
|
297
|
+
secrets,
|
|
298
|
+
kekName: "KEK_TEST",
|
|
299
|
+
dekStore: store,
|
|
300
|
+
maxRecordsPerDek: 2,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const r1 = await enc.encryptPayload({ n: 1 }, "tenant-a");
|
|
304
|
+
const r2 = await enc.encryptPayload({ n: 2 }, "tenant-a");
|
|
305
|
+
const r3 = await enc.encryptPayload({ n: 3 }, "tenant-a");
|
|
306
|
+
// First two records share DEK v1; the third rolls to v2.
|
|
307
|
+
expect(r1.dekRef).toBe("dek:tenant-a:v1");
|
|
308
|
+
expect(r2.dekRef).toBe("dek:tenant-a:v1");
|
|
309
|
+
expect(r3.dekRef).toBe("dek:tenant-a:v2");
|
|
310
|
+
// All three still decrypt.
|
|
311
|
+
expect(await enc.decryptPayload(r1)).toEqual({ n: 1 });
|
|
312
|
+
expect(await enc.decryptPayload(r2)).toEqual({ n: 2 });
|
|
313
|
+
expect(await enc.decryptPayload(r3)).toEqual({ n: 3 });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("decrypt throws if no KEK material is retained for the record's kekRef", async () => {
|
|
317
|
+
const enc = await buildEncryption();
|
|
318
|
+
const record = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
319
|
+
const orphaned = { ...record, kekRef: "kek:KEK_TEST:unknown-9999" };
|
|
320
|
+
await expect(enc.decryptPayload(orphaned)).rejects.toThrow(/no KEK material retained/);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("#163/#164 follow-up — persistent FileDekStore across restart", () => {
|
|
325
|
+
const tmpDirs: string[] = [];
|
|
326
|
+
function freshDir(): string {
|
|
327
|
+
const d = mkdtempSync(join(tmpdir(), "audit-enc-dek-"));
|
|
328
|
+
tmpDirs.push(d);
|
|
329
|
+
return d;
|
|
330
|
+
}
|
|
331
|
+
afterAll(() => {
|
|
332
|
+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const V1 = "kek-v1-secret-12345678";
|
|
336
|
+
const V2 = "kek-v2-secret-87654321";
|
|
337
|
+
const V1_REF = "kek:KEK_TEST:v1";
|
|
338
|
+
const V2_REF = "kek:KEK_TEST:v2";
|
|
339
|
+
|
|
340
|
+
test("FileDekStore is a drop-in DekStore: encrypt + decrypt round-trip", async () => {
|
|
341
|
+
const dir = freshDir();
|
|
342
|
+
setKek("KEK_TEST", V1);
|
|
343
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
344
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
345
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
346
|
+
const rec = await enc.encryptPayload({ event: "x" }, "tenant-a");
|
|
347
|
+
expect(await enc.decryptPayload(rec)).toEqual({ event: "x" });
|
|
348
|
+
// The tenant's DEK file exists on disk after the first write.
|
|
349
|
+
expect(existsSync(join(dir, "dek-tenant-a.json"))).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("persisted DEK file is wrapped (no raw key bytes, no KEK value) and 0o600", async () => {
|
|
353
|
+
const dir = freshDir();
|
|
354
|
+
setKek("KEK_TEST", V1);
|
|
355
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
356
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
357
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
358
|
+
await enc.encryptPayload({ event: "x" }, "tenant-a");
|
|
359
|
+
|
|
360
|
+
const file = join(dir, "dek-tenant-a.json");
|
|
361
|
+
// File is owner-read/write only (0o600). Mask to the permission bits.
|
|
362
|
+
expect(statSync(file).mode & 0o777).toBe(0o600);
|
|
363
|
+
|
|
364
|
+
const raw = readFileSync(file, "utf8");
|
|
365
|
+
const parsed = JSON.parse(raw);
|
|
366
|
+
// Persists ONLY the wrapped DEK + binding metadata — never the KEK value.
|
|
367
|
+
expect(parsed).toMatchObject({
|
|
368
|
+
version: 1,
|
|
369
|
+
uses: 1,
|
|
370
|
+
kekRef: V1_REF,
|
|
371
|
+
kekSalt: expect.stringMatching(/^[a-f0-9]{32}$/),
|
|
372
|
+
wrappedDek: expect.stringMatching(/^[a-f0-9]+$/),
|
|
373
|
+
wrappedDekIv: expect.stringMatching(/^[a-f0-9]{24}$/),
|
|
374
|
+
wrappedDekTag: expect.stringMatching(/^[a-f0-9]{32}$/),
|
|
375
|
+
});
|
|
376
|
+
expect(parsed).not.toHaveProperty("dek");
|
|
377
|
+
// The operator KEK value must not appear anywhere in the file.
|
|
378
|
+
expect(raw).not.toContain(V1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("FRESH engine on the same file store decrypts a pre-rotation record after restart", async () => {
|
|
382
|
+
const dir = freshDir();
|
|
383
|
+
|
|
384
|
+
// --- Process 1: boot under v1, encrypt, then rotate to v2. ---
|
|
385
|
+
setKek("KEK_TEST", V1);
|
|
386
|
+
const secrets1 = createSecrets({ backend: createEnvVarBackend() });
|
|
387
|
+
const store1 = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
388
|
+
const enc1 = await createAuditEncryption({
|
|
389
|
+
secrets: secrets1,
|
|
390
|
+
kekName: "KEK_TEST",
|
|
391
|
+
dekStore: store1,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const before = await enc1.encryptPayload({ era: "v1" }, "tenant-a");
|
|
395
|
+
expect(before.kekRef).toBe(V1_REF);
|
|
396
|
+
expect(before.dekRef).toBe("dek:tenant-a:v1");
|
|
397
|
+
|
|
398
|
+
await enc1.rotateKek(V2, V2_REF);
|
|
399
|
+
expect(enc1.kekRef).toBe(V2_REF);
|
|
400
|
+
|
|
401
|
+
// --- Simulate a restart: brand-new engine + store, same dir. The
|
|
402
|
+
// operator re-provides the current (post-rotation) KEK as the boot
|
|
403
|
+
// value and re-supplies the prior KEK as retained material. Nothing
|
|
404
|
+
// from process 1's in-memory state carries over. ---
|
|
405
|
+
setKek("KEK_TEST", V2); // backend now returns the rotated value
|
|
406
|
+
const secrets2 = createSecrets({ backend: createEnvVarBackend() });
|
|
407
|
+
const store2 = createFileDekStore(
|
|
408
|
+
dir,
|
|
409
|
+
staticKekProvider({ kekRef: V2_REF, kekValue: V2 }, [{ kekRef: V1_REF, kekValue: V1 }]),
|
|
410
|
+
);
|
|
411
|
+
const enc2 = await createAuditEncryption({
|
|
412
|
+
secrets: secrets2,
|
|
413
|
+
kekName: "KEK_TEST",
|
|
414
|
+
kekRef: V2_REF, // boot value is the v2 material; pin its true ref
|
|
415
|
+
retainedKeks: [{ kekRef: V1_REF, kekValue: V1 }],
|
|
416
|
+
dekStore: store2,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// The pre-rotation record (sealed under v1) still decrypts.
|
|
420
|
+
expect(await enc2.decryptPayload(before)).toEqual({ era: "v1" });
|
|
421
|
+
|
|
422
|
+
// The DEK use-count + version survived the restart: the rotation in
|
|
423
|
+
// process 1 re-minted tenant-a to v2, and the fresh engine sees that
|
|
424
|
+
// (it does NOT reset to a fresh v1 DEK).
|
|
425
|
+
const after = await enc2.encryptPayload({ era: "v2" }, "tenant-a");
|
|
426
|
+
expect(after.kekRef).toBe(V2_REF);
|
|
427
|
+
expect(after.dekRef).toBe("dek:tenant-a:v2");
|
|
428
|
+
expect(await enc2.decryptPayload(after)).toEqual({ era: "v2" });
|
|
429
|
+
}, 20_000);
|
|
430
|
+
|
|
431
|
+
test("use-count survives restart (DEK does not roll prematurely)", async () => {
|
|
432
|
+
const dir = freshDir();
|
|
433
|
+
setKek("KEK_TEST", V1);
|
|
434
|
+
|
|
435
|
+
// Process 1: two writes against a maxRecordsPerDek of 3 (uses -> 2).
|
|
436
|
+
const secrets1 = createSecrets({ backend: createEnvVarBackend() });
|
|
437
|
+
const store1 = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
438
|
+
const enc1 = await createAuditEncryption({
|
|
439
|
+
secrets: secrets1,
|
|
440
|
+
kekName: "KEK_TEST",
|
|
441
|
+
dekStore: store1,
|
|
442
|
+
maxRecordsPerDek: 3,
|
|
443
|
+
});
|
|
444
|
+
const r1 = await enc1.encryptPayload({ n: 1 }, "tenant-a");
|
|
445
|
+
const r2 = await enc1.encryptPayload({ n: 2 }, "tenant-a");
|
|
446
|
+
expect(r1.dekRef).toBe("dek:tenant-a:v1");
|
|
447
|
+
expect(r2.dekRef).toBe("dek:tenant-a:v1");
|
|
448
|
+
|
|
449
|
+
// Restart: fresh engine + store on the same dir, same KEK. The use
|
|
450
|
+
// counter is read from disk (=2), so the 3rd write stays on v1 and the
|
|
451
|
+
// 4th rolls to v2 — proving the counter was not reset to 0.
|
|
452
|
+
const secrets2 = createSecrets({ backend: createEnvVarBackend() });
|
|
453
|
+
const store2 = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
454
|
+
const enc2 = await createAuditEncryption({
|
|
455
|
+
secrets: secrets2,
|
|
456
|
+
kekName: "KEK_TEST",
|
|
457
|
+
dekStore: store2,
|
|
458
|
+
maxRecordsPerDek: 3,
|
|
459
|
+
});
|
|
460
|
+
const r3 = await enc2.encryptPayload({ n: 3 }, "tenant-a");
|
|
461
|
+
const r4 = await enc2.encryptPayload({ n: 4 }, "tenant-a");
|
|
462
|
+
expect(r3.dekRef).toBe("dek:tenant-a:v1"); // 3rd use still fits v1
|
|
463
|
+
expect(r4.dekRef).toBe("dek:tenant-a:v2"); // 4th rolls — counter persisted
|
|
464
|
+
|
|
465
|
+
// All four records still decrypt under the persisted DEKs.
|
|
466
|
+
expect(await enc2.decryptPayload(r1)).toEqual({ n: 1 });
|
|
467
|
+
expect(await enc2.decryptPayload(r3)).toEqual({ n: 3 });
|
|
468
|
+
expect(await enc2.decryptPayload(r4)).toEqual({ n: 4 });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("reading a DEK file without its KEK material throws a clear error", async () => {
|
|
472
|
+
const dir = freshDir();
|
|
473
|
+
setKek("KEK_TEST", V1);
|
|
474
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
475
|
+
// Persist a DEK wrapped under v1...
|
|
476
|
+
const writeStore = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
477
|
+
const enc = await createAuditEncryption({
|
|
478
|
+
secrets,
|
|
479
|
+
kekName: "KEK_TEST",
|
|
480
|
+
dekStore: writeStore,
|
|
481
|
+
});
|
|
482
|
+
await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
483
|
+
|
|
484
|
+
// ...then try to read it with a provider that lacks v1 entirely.
|
|
485
|
+
const blindStore = createFileDekStore(dir, staticKekProvider({ kekRef: V2_REF, kekValue: V2 }));
|
|
486
|
+
await expect(blindStore.getEntry?.("tenant-a")).rejects.toThrow(/no KEK material for kekRef/);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("createFileDekStore rejects an empty rootDir", () => {
|
|
490
|
+
expect(() =>
|
|
491
|
+
createFileDekStore("", staticKekProvider({ kekRef: V1_REF, kekValue: V1 })),
|
|
492
|
+
).toThrow(/rootDir is required/);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("FileDekStore.tenants lists persisted tenants for rotation", async () => {
|
|
496
|
+
const dir = freshDir();
|
|
497
|
+
setKek("KEK_TEST", V1);
|
|
498
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
499
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
500
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
501
|
+
await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
502
|
+
await enc.encryptPayload({ x: 1 }, "tenant-b");
|
|
503
|
+
const tenants = await store.tenants?.();
|
|
504
|
+
expect([...(tenants ?? [])].sort()).toEqual(["tenant-a", "tenant-b"]);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("FileDekStore get/set (non-versioned DekStore surface)", () => {
|
|
509
|
+
const tmpDirs: string[] = [];
|
|
510
|
+
function freshDir(): string {
|
|
511
|
+
const d = mkdtempSync(join(tmpdir(), "audit-enc-getset-"));
|
|
512
|
+
tmpDirs.push(d);
|
|
513
|
+
return d;
|
|
514
|
+
}
|
|
515
|
+
afterAll(() => {
|
|
516
|
+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const V1 = "kek-v1-secret-12345678";
|
|
520
|
+
const V1_REF = "kek:KEK_TEST:v1";
|
|
521
|
+
|
|
522
|
+
test("get returns undefined for an unknown tenant", async () => {
|
|
523
|
+
const dir = freshDir();
|
|
524
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
525
|
+
expect(await store.get("nobody")).toBeUndefined();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("set then get round-trips the raw DEK through wrap/unwrap", async () => {
|
|
529
|
+
const dir = freshDir();
|
|
530
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
531
|
+
const dek = randomBytes(32);
|
|
532
|
+
await store.set("tenant-a", dek);
|
|
533
|
+
const got = await store.get("tenant-a");
|
|
534
|
+
expect(got).toBeDefined();
|
|
535
|
+
// The plain get/set surface persists+restores the exact DEK bytes.
|
|
536
|
+
expect(Buffer.from(got as Buffer).equals(dek)).toBe(true);
|
|
537
|
+
// Persisted as version 1 with a reset use-count.
|
|
538
|
+
const entry = await store.getEntry?.("tenant-a");
|
|
539
|
+
expect(entry?.version).toBe(1);
|
|
540
|
+
expect(entry?.uses).toBe(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("set preserves the existing version on overwrite (prev.version branch)", async () => {
|
|
544
|
+
const dir = freshDir();
|
|
545
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
546
|
+
// Seed a v3 entry via the versioned surface...
|
|
547
|
+
await store.setEntry?.("tenant-a", { dek: randomBytes(32), version: 3, uses: 7 });
|
|
548
|
+
// ...then a plain set must keep version 3 (prev?.version ?? 1 -> 3) and
|
|
549
|
+
// reset uses to 0.
|
|
550
|
+
const replacement = randomBytes(32);
|
|
551
|
+
await store.set("tenant-a", replacement);
|
|
552
|
+
const entry = await store.getEntry?.("tenant-a");
|
|
553
|
+
expect(entry?.version).toBe(3);
|
|
554
|
+
expect(entry?.uses).toBe(0);
|
|
555
|
+
expect(Buffer.from(entry?.dek as Buffer).equals(replacement)).toBe(true);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe("get/set-only DekStore (engine fallback to non-versioned surface)", () => {
|
|
560
|
+
// A store exposing ONLY get/set — no getEntry/setEntry/tenants. Exercises
|
|
561
|
+
// the engine's readEntry/writeEntry fallbacks and the rotateKek no-op when
|
|
562
|
+
// the store cannot iterate tenants.
|
|
563
|
+
class MinimalDekStore {
|
|
564
|
+
readonly map = new Map<string, Buffer>();
|
|
565
|
+
async get(tenantId: string): Promise<Buffer | undefined> {
|
|
566
|
+
const v = this.map.get(tenantId);
|
|
567
|
+
return v === undefined ? undefined : Buffer.from(v);
|
|
568
|
+
}
|
|
569
|
+
async set(tenantId: string, dek: Buffer): Promise<void> {
|
|
570
|
+
this.map.set(tenantId, Buffer.from(dek));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
test("encrypt+decrypt round-trips and the DEK is treated as version 1", async () => {
|
|
575
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
576
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
577
|
+
const store = new MinimalDekStore();
|
|
578
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
579
|
+
const rec = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
580
|
+
expect(rec.dekRef).toBe("dek:tenant-a:v1");
|
|
581
|
+
expect(await enc.decryptPayload(rec)).toEqual({ x: 1 });
|
|
582
|
+
// The engine persisted a 32-byte DEK via the plain set surface.
|
|
583
|
+
expect((await store.get("tenant-a"))?.length).toBe(32);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("a stored DEK of the wrong length is treated as missing and re-minted", async () => {
|
|
587
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
588
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
589
|
+
const store = new MinimalDekStore();
|
|
590
|
+
// Pre-seed a malformed (too-short) DEK: readEntry must reject it.
|
|
591
|
+
store.map.set("tenant-a", Buffer.from("short"));
|
|
592
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
593
|
+
const rec = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
594
|
+
expect(rec.dekRef).toBe("dek:tenant-a:v1");
|
|
595
|
+
// It overwrote the malformed value with a real 32-byte DEK.
|
|
596
|
+
expect((await store.get("tenant-a"))?.length).toBe(32);
|
|
597
|
+
expect(await enc.decryptPayload(rec)).toEqual({ x: 1 });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("rotateKek is a no-op on the store when tenants() is unavailable", async () => {
|
|
601
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
602
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
603
|
+
const store = new MinimalDekStore();
|
|
604
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
605
|
+
const before = await enc.encryptPayload({ era: "v1" }, "tenant-a");
|
|
606
|
+
// No tenants() -> rotation cannot re-mint per tenant, but must not throw
|
|
607
|
+
// and historical records must still decrypt.
|
|
608
|
+
await enc.rotateKek("kek-v2-secret-87654321", "kek:KEK_TEST:v2");
|
|
609
|
+
expect(enc.kekRef).toBe("kek:KEK_TEST:v2");
|
|
610
|
+
expect(await enc.decryptPayload(before)).toEqual({ era: "v1" });
|
|
611
|
+
const after = await enc.encryptPayload({ era: "v2" }, "tenant-a");
|
|
612
|
+
expect(after.kekRef).toBe("kek:KEK_TEST:v2");
|
|
613
|
+
expect(await enc.decryptPayload(after)).toEqual({ era: "v2" });
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("auto-subscribed rotation via secrets.onRotation", () => {
|
|
618
|
+
test("a rotation event for the configured KEK re-keys and stays decryptable", async () => {
|
|
619
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
620
|
+
const store = new InMemoryDekStore();
|
|
621
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
622
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
623
|
+
|
|
624
|
+
const before = await enc.encryptPayload({ era: "v1" }, "tenant-a");
|
|
625
|
+
expect(before.kekRef).toBe("kek:KEK_TEST:v1");
|
|
626
|
+
|
|
627
|
+
// Firing the backend rotation drives the engine's onRotation handler
|
|
628
|
+
// (no direct rotateKek call). Await it so the async handler settles.
|
|
629
|
+
await secrets.rotate("KEK_TEST", { newValue: "kek-v2-secret-87654321" });
|
|
630
|
+
|
|
631
|
+
// The engine adopted the rotated value: its kekRef now carries the
|
|
632
|
+
// `kek:KEK_TEST:<rotatedAt>` shape the handler mints.
|
|
633
|
+
expect(enc.kekRef).toMatch(/^kek:KEK_TEST:\d+$/);
|
|
634
|
+
expect(enc.kekRef).not.toBe("kek:KEK_TEST:v1");
|
|
635
|
+
|
|
636
|
+
// Pre-rotation record still decrypts; new writes use the rotated KEK.
|
|
637
|
+
expect(await enc.decryptPayload(before)).toEqual({ era: "v1" });
|
|
638
|
+
const after = await enc.encryptPayload({ era: "v2" }, "tenant-a");
|
|
639
|
+
expect(after.kekRef).toBe(enc.kekRef);
|
|
640
|
+
expect(await enc.decryptPayload(after)).toEqual({ era: "v2" });
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("a failing event-driven rotation does not surface an unhandled rejection", async () => {
|
|
644
|
+
// Regression: the onRotation handler used to fire-and-forget the re-key
|
|
645
|
+
// with a bare `void rotateInternal(...)`. If that promise rejected
|
|
646
|
+
// (e.g. a DEK-store failure mid-rotation), the rejection escaped as an
|
|
647
|
+
// unhandledRejection and could crash the host process. The handler now
|
|
648
|
+
// contains the rejection with `.catch`.
|
|
649
|
+
class ExplodingTenantsStore extends InMemoryDekStore {
|
|
650
|
+
override async tenants(): Promise<ReadonlyArray<string>> {
|
|
651
|
+
throw new Error("boom: tenants() unavailable during rotation");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
655
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
656
|
+
const store = new ExplodingTenantsStore();
|
|
657
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
658
|
+
const before = await enc.encryptPayload({ era: "v1" }, "tenant-a");
|
|
659
|
+
|
|
660
|
+
const rejections: unknown[] = [];
|
|
661
|
+
const onUnhandled = (reason: unknown): void => {
|
|
662
|
+
rejections.push(reason);
|
|
663
|
+
};
|
|
664
|
+
process.on("unhandledRejection", onUnhandled);
|
|
665
|
+
try {
|
|
666
|
+
// Drive the event-rotation; rotateInternal will reject inside tenants().
|
|
667
|
+
await secrets.rotate("KEK_TEST", { newValue: "kek-v2-secret-87654321" });
|
|
668
|
+
// Let any unhandled rejection surface on the macrotask queue.
|
|
669
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
670
|
+
} finally {
|
|
671
|
+
process.off("unhandledRejection", onUnhandled);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
expect(rejections).toHaveLength(0);
|
|
675
|
+
// The synchronous half of the rotation still adopted the new KEK, and
|
|
676
|
+
// the pre-rotation record remains decryptable.
|
|
677
|
+
expect(enc.kekRef).toMatch(/^kek:KEK_TEST:\d+$/);
|
|
678
|
+
expect(await enc.decryptPayload(before)).toEqual({ era: "v1" });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("a rotation event for a different secret name is ignored", async () => {
|
|
682
|
+
setKek("KEK_TEST", "kek-v1-secret-12345678");
|
|
683
|
+
setKek("OTHER_SECRET", "unrelated-value-000000");
|
|
684
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
685
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST" });
|
|
686
|
+
const refBefore = enc.kekRef;
|
|
687
|
+
// Rotating a secret this engine does not care about must not change its
|
|
688
|
+
// KEK (exercises the name-guard early return in the handler).
|
|
689
|
+
await secrets.rotate("OTHER_SECRET", { newValue: "rotated-unrelated-1111" });
|
|
690
|
+
expect(enc.kekRef).toBe(refBefore);
|
|
691
|
+
const rec = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
692
|
+
expect(rec.kekRef).toBe(refBefore);
|
|
693
|
+
expect(await enc.decryptPayload(rec)).toEqual({ x: 1 });
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
describe("maxRecordsPerDek guard", () => {
|
|
698
|
+
test("a non-positive maxRecordsPerDek falls back to the default (no premature roll)", async () => {
|
|
699
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
700
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
701
|
+
const store = new InMemoryDekStore();
|
|
702
|
+
// 0 is not > 0, so the default threshold applies and the DEK does not
|
|
703
|
+
// roll on every record.
|
|
704
|
+
const enc = await createAuditEncryption({
|
|
705
|
+
secrets,
|
|
706
|
+
kekName: "KEK_TEST",
|
|
707
|
+
dekStore: store,
|
|
708
|
+
maxRecordsPerDek: 0,
|
|
709
|
+
});
|
|
710
|
+
const a = await enc.encryptPayload({ n: 1 }, "tenant-a");
|
|
711
|
+
const b = await enc.encryptPayload({ n: 2 }, "tenant-a");
|
|
712
|
+
expect(a.dekRef).toBe("dek:tenant-a:v1");
|
|
713
|
+
expect(b.dekRef).toBe("dek:tenant-a:v1");
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
describe("explicit kekRef option", () => {
|
|
718
|
+
test("a custom boot kekRef is stamped on records and used for decrypt", async () => {
|
|
719
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
720
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
721
|
+
const enc = await createAuditEncryption({
|
|
722
|
+
secrets,
|
|
723
|
+
kekName: "KEK_TEST",
|
|
724
|
+
kekRef: "kek:KEK_TEST:custom-boot",
|
|
725
|
+
});
|
|
726
|
+
expect(enc.kekRef).toBe("kek:KEK_TEST:custom-boot");
|
|
727
|
+
const rec = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
728
|
+
expect(rec.kekRef).toBe("kek:KEK_TEST:custom-boot");
|
|
729
|
+
expect(await enc.decryptPayload(rec)).toEqual({ x: 1 });
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("a retained KEK under the boot ref does not shadow the boot value", async () => {
|
|
733
|
+
// Boot KEK occupies kek:KEK_TEST:v1; a stale retained entry under the
|
|
734
|
+
// same ref must be ignored (the !has guard in createAuditEncryption).
|
|
735
|
+
setKek("KEK_TEST", "real-boot-kek-12345678");
|
|
736
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
737
|
+
const enc = await createAuditEncryption({
|
|
738
|
+
secrets,
|
|
739
|
+
kekName: "KEK_TEST",
|
|
740
|
+
retainedKeks: [{ kekRef: "kek:KEK_TEST:v1", kekValue: "stale-shadow-value-000" }],
|
|
741
|
+
});
|
|
742
|
+
// If the stale value had shadowed the boot value, decrypt would fail
|
|
743
|
+
// (different scrypt key). It round-trips because the boot value wins.
|
|
744
|
+
const rec = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
745
|
+
expect(await enc.decryptPayload(rec)).toEqual({ x: 1 });
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
describe("decryptPayload — non-JSON plaintext", () => {
|
|
750
|
+
test("a record whose decrypted plaintext is not JSON throws AuditEncryptionError", async () => {
|
|
751
|
+
setKek("KEK_TEST", "kek-secret-12345678");
|
|
752
|
+
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
753
|
+
const store = new InMemoryDekStore();
|
|
754
|
+
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
755
|
+
|
|
756
|
+
// Encrypt a real record to obtain a valid wrapped DEK + kekSalt, then
|
|
757
|
+
// hand-build a payload ciphertext over NON-JSON bytes under the same DEK
|
|
758
|
+
// so unwrap succeeds but JSON.parse fails.
|
|
759
|
+
const seed = await enc.encryptPayload({ ok: true }, "tenant-a");
|
|
760
|
+
const dek = (await store.get("tenant-a")) as Buffer;
|
|
761
|
+
const badIv = randomBytes(12);
|
|
762
|
+
const c = createCipheriv("aes-256-gcm", dek, badIv);
|
|
763
|
+
const ct = Buffer.concat([c.update(Buffer.from("not-json{", "utf8")), c.final()]);
|
|
764
|
+
const tag = c.getAuthTag();
|
|
765
|
+
const bad: EncryptedRecord = {
|
|
766
|
+
...seed,
|
|
767
|
+
iv: badIv.toString("hex"),
|
|
768
|
+
tag: tag.toString("hex"),
|
|
769
|
+
encryptedPayload: ct.toString("hex"),
|
|
770
|
+
};
|
|
771
|
+
await expect(enc.decryptPayload(bad)).rejects.toThrow(/not valid JSON/);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
describe("corrupt persisted DEK file", () => {
|
|
776
|
+
const tmpDirs: string[] = [];
|
|
777
|
+
function freshDir(): string {
|
|
778
|
+
const d = mkdtempSync(join(tmpdir(), "audit-enc-corrupt-"));
|
|
779
|
+
tmpDirs.push(d);
|
|
780
|
+
return d;
|
|
781
|
+
}
|
|
782
|
+
afterAll(() => {
|
|
783
|
+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("a non-JSON DEK file surfaces a clear AuditEncryptionError", async () => {
|
|
787
|
+
const dir = freshDir();
|
|
788
|
+
const V1 = "kek-v1-secret-12345678";
|
|
789
|
+
const V1_REF = "kek:KEK_TEST:v1";
|
|
790
|
+
const store = createFileDekStore(dir, staticKekProvider({ kekRef: V1_REF, kekValue: V1 }));
|
|
791
|
+
// Hand-write garbage into the tenant's DEK file.
|
|
792
|
+
writeFileSync(join(dir, "dek-tenant-a.json"), "{ this is not json", "utf8");
|
|
793
|
+
await expect(store.get("tenant-a")).rejects.toThrow(/corrupt DEK file/);
|
|
794
|
+
await expect(store.getEntry?.("tenant-a")).rejects.toThrow(/corrupt DEK file/);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
describe("low-level crypto test seams", () => {
|
|
799
|
+
test("encryptBytes/decryptBytes round-trip", () => {
|
|
800
|
+
const key = randomBytes(32);
|
|
801
|
+
const iv = randomBytes(12);
|
|
802
|
+
const { ciphertext, tag } = _encryptBytesForTest(Buffer.from("hello world"), key, iv);
|
|
803
|
+
const out = _decryptBytesForTest(ciphertext, key, iv, tag);
|
|
804
|
+
expect(out.toString("utf8")).toBe("hello world");
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("deriveKekKey is salt-dependent and 32 bytes; legacy derivation is bare SHA-256", () => {
|
|
808
|
+
const salt = randomBytes(16);
|
|
809
|
+
const k1 = _deriveKekKeyForTest("kek-value", salt);
|
|
810
|
+
const k2 = _deriveKekKeyForTest("kek-value", randomBytes(16));
|
|
811
|
+
expect(k1.length).toBe(32);
|
|
812
|
+
// Different salt => different derived key.
|
|
813
|
+
expect(k1.equals(k2)).toBe(false);
|
|
814
|
+
const legacy = _deriveKekKeyLegacyForTest("kek-value");
|
|
815
|
+
expect(legacy.equals(createHash("sha256").update("kek-value").digest())).toBe(true);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
describe("staticKekProvider", () => {
|
|
820
|
+
test("resolves current + retained, current wins on ref collision", () => {
|
|
821
|
+
const p = staticKekProvider({ kekRef: "kek:a:v2", kekValue: "current-value" }, [
|
|
822
|
+
{ kekRef: "kek:a:v1", kekValue: "old-value" },
|
|
823
|
+
// A stale retained entry under the current ref must not shadow it.
|
|
824
|
+
{ kekRef: "kek:a:v2", kekValue: "stale-value" },
|
|
825
|
+
]);
|
|
826
|
+
expect(p.current()).toEqual({ kekRef: "kek:a:v2", kekValue: "current-value" });
|
|
827
|
+
expect(p.resolve("kek:a:v1")).toBe("old-value");
|
|
828
|
+
expect(p.resolve("kek:a:v2")).toBe("current-value");
|
|
829
|
+
expect(p.resolve("kek:a:unknown")).toBeUndefined();
|
|
830
|
+
});
|
|
831
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,17 @@ import {
|
|
|
5
5
|
createDecipheriv,
|
|
6
6
|
createHash,
|
|
7
7
|
randomBytes,
|
|
8
|
+
scryptSync,
|
|
8
9
|
} from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
9
19
|
import { CrewhausError } from "@crewhaus/errors";
|
|
10
20
|
import type { Secrets } from "@crewhaus/secrets-manager";
|
|
11
21
|
|
|
@@ -17,7 +27,7 @@ import type { Secrets } from "@crewhaus/secrets-manager";
|
|
|
17
27
|
* Encryption Key (DEK); the DEK itself is encrypted ("wrapped") with
|
|
18
28
|
* a Key Encryption Key (KEK) sourced from §27 `secrets-manager`. The
|
|
19
29
|
* resulting record carries
|
|
20
|
-
* { tenantId, kekRef, dekRef, iv, tag, encryptedPayload }
|
|
30
|
+
* { tenantId, kekRef, dekRef, kekSalt, iv, tag, encryptedPayload, ... }
|
|
21
31
|
* and is verifiable + decryptable by any caller with the same KEK.
|
|
22
32
|
*
|
|
23
33
|
* Algorithms:
|
|
@@ -25,13 +35,25 @@ import type { Secrets } from "@crewhaus/secrets-manager";
|
|
|
25
35
|
* authenticated, so tampering with `encryptedPayload`, `iv`, or
|
|
26
36
|
* `tag` causes `decrypt` to throw — satisfies the §39 T8
|
|
27
37
|
* ciphertext-integrity requirement.
|
|
38
|
+
* - The 32-byte AES wrapping key is derived from the KEK string via
|
|
39
|
+
* scrypt (a salted, memory-hard KDF) with a per-record random salt.
|
|
40
|
+
* This holds even when the KEK is a low-entropy passphrase: scrypt
|
|
41
|
+
* stretches it and the persisted salt defeats precomputation
|
|
42
|
+
* (CWE-916 — a bare unsalted hash would not). The salt is stored on
|
|
43
|
+
* the record (`kekSalt`) so the same key can be re-derived at
|
|
44
|
+
* unwrap time.
|
|
28
45
|
* - 12-byte (96-bit) IVs randomly generated per record.
|
|
29
46
|
*
|
|
30
47
|
* Key rotation:
|
|
31
|
-
* `secrets.onRotation(...)` triggers `rotateKek()` which
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
48
|
+
* `secrets.onRotation(...)` triggers `rotateKek()` which mints a fresh
|
|
49
|
+
* DEK version (`dek:<tenant>:vN+1`) for every tenant in the DEK store
|
|
50
|
+
* and adopts the new KEK as current. The prior KEK *value* is retained
|
|
51
|
+
* in-process keyed by its `kekRef`, so `decryptPayload` can re-derive
|
|
52
|
+
* the wrapping key for historical records (which keep their original
|
|
53
|
+
* `kekRef` + `kekSalt`) and still unwrap them (CWE-323 — without
|
|
54
|
+
* retaining prior material, rotation would strand old records).
|
|
55
|
+
* DEKs also roll automatically once a single version has wrapped more
|
|
56
|
+
* than `maxRecordsPerDek` records.
|
|
35
57
|
*
|
|
36
58
|
* Layer R17. Pairs with `audit-log` (R-infra — wraps `append` /
|
|
37
59
|
* `read`) and `secrets-manager` (§27 — KEK source).
|
|
@@ -51,6 +73,12 @@ export type EncryptedRecord = {
|
|
|
51
73
|
readonly kekRef: string;
|
|
52
74
|
/** Stable identifier for the DEK used to encrypt the payload. */
|
|
53
75
|
readonly dekRef: string;
|
|
76
|
+
/**
|
|
77
|
+
* Per-record salt (hex) fed to the scrypt KEK-key derivation. Absent on
|
|
78
|
+
* legacy records written before the KDF migration; those fall back to
|
|
79
|
+
* the legacy unsalted-SHA-256 derivation for back-compat.
|
|
80
|
+
*/
|
|
81
|
+
readonly kekSalt?: string;
|
|
54
82
|
/** 96-bit GCM IV (24 hex chars). */
|
|
55
83
|
readonly iv: string;
|
|
56
84
|
/** 128-bit GCM auth tag (32 hex chars). */
|
|
@@ -69,40 +97,309 @@ export type AuditEncryptionOptions = {
|
|
|
69
97
|
readonly secrets: Secrets;
|
|
70
98
|
/** Name of the KEK in §27 secrets-manager. */
|
|
71
99
|
readonly kekName: string;
|
|
100
|
+
/**
|
|
101
|
+
* Stable identifier for the *boot* KEK value. Defaults to
|
|
102
|
+
* `kek:<kekName>:v1`. This is the `kekRef` stamped on records sealed
|
|
103
|
+
* before the first in-process rotation, and the key under which the
|
|
104
|
+
* boot KEK is held in the retain-for-decrypt registry. After one or
|
|
105
|
+
* more rotations, a restarted process boots with the *latest* KEK
|
|
106
|
+
* value; pass that rotation's ref here so historical refs stay stable
|
|
107
|
+
* and the boot value is not mistaken for the original `:v1` material.
|
|
108
|
+
*/
|
|
109
|
+
readonly kekRef?: string;
|
|
72
110
|
/**
|
|
73
111
|
* Optional persistent DEK store. If omitted, DEKs live in-memory
|
|
74
112
|
* (process-local). Production should plug a tenant-scoped key store
|
|
75
|
-
* here (HSM, KMS, vault path)
|
|
113
|
+
* here (HSM, KMS, vault path) — see {@link createFileDekStore} for a
|
|
114
|
+
* file-backed implementation that survives restart.
|
|
76
115
|
*/
|
|
77
116
|
readonly dekStore?: DekStore;
|
|
78
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Prior KEK material to re-seed at boot, keyed by the `kekRef` it was
|
|
119
|
+
* minted under. The in-process KEK registry that {@link rotateKek}
|
|
120
|
+
* populates does not survive a restart, so a freshly-constructed engine
|
|
121
|
+
* can only unwrap records sealed under the *boot* KEK. Operators that
|
|
122
|
+
* have rotated must re-provide each superseded KEK here so historical
|
|
123
|
+
* records (which embed their original `kekRef`) keep decrypting after a
|
|
124
|
+
* restart (CWE-323). Values are never persisted by this package; the
|
|
125
|
+
* operator re-supplies them from the secret backend's history.
|
|
126
|
+
*/
|
|
127
|
+
readonly retainedKeks?: ReadonlyArray<{ readonly kekRef: string; readonly kekValue: string }>;
|
|
128
|
+
/**
|
|
129
|
+
* Roll a tenant's DEK to a fresh version once it has wrapped this many
|
|
130
|
+
* records. Bounds the blast radius of any single DEK. Defaults to
|
|
131
|
+
* {@link DEFAULT_MAX_RECORDS_PER_DEK}.
|
|
132
|
+
*/
|
|
133
|
+
readonly maxRecordsPerDek?: number;
|
|
134
|
+
/** Test seam: deterministic IV/salt generator. */
|
|
79
135
|
readonly randomBytesImpl?: (n: number) => Buffer;
|
|
80
136
|
/** Test seam: synthetic Date.now. */
|
|
81
137
|
readonly now?: () => number;
|
|
82
138
|
};
|
|
83
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Versioned DEK entry. `version` is the integer N behind the
|
|
142
|
+
* `dek:<tenant>:vN` ref; `uses` counts records encrypted under it so we
|
|
143
|
+
* can roll on the {@link AuditEncryptionOptions.maxRecordsPerDek}
|
|
144
|
+
* threshold.
|
|
145
|
+
*/
|
|
146
|
+
export type DekEntry = {
|
|
147
|
+
readonly dek: Buffer;
|
|
148
|
+
readonly version: number;
|
|
149
|
+
readonly uses: number;
|
|
150
|
+
};
|
|
151
|
+
|
|
84
152
|
export interface DekStore {
|
|
85
153
|
get(tenantId: string): Promise<Buffer | undefined>;
|
|
86
154
|
set(tenantId: string, dek: Buffer): Promise<void>;
|
|
155
|
+
/**
|
|
156
|
+
* Optional versioned read. When present it is preferred over `get`, and
|
|
157
|
+
* carries the version + usage counter needed for rotation. Stores that
|
|
158
|
+
* implement only `get`/`set` are treated as version 1 with no usage
|
|
159
|
+
* tracking (rotation still re-mints; the threshold is a no-op).
|
|
160
|
+
*/
|
|
161
|
+
getEntry?(tenantId: string): Promise<DekEntry | undefined>;
|
|
162
|
+
/** Optional versioned write. Required for DEK versioning to take effect. */
|
|
163
|
+
setEntry?(tenantId: string, entry: DekEntry): Promise<void>;
|
|
164
|
+
/** Optional iteration over tenants holding a DEK. Required by `rotateKek`. */
|
|
165
|
+
tenants?(): Promise<ReadonlyArray<string>>;
|
|
87
166
|
}
|
|
88
167
|
|
|
89
168
|
export class InMemoryDekStore implements DekStore {
|
|
90
|
-
private readonly map
|
|
169
|
+
private readonly map: Map<string, DekEntry>;
|
|
170
|
+
constructor() {
|
|
171
|
+
this.map = new Map<string, DekEntry>();
|
|
172
|
+
}
|
|
91
173
|
async get(tenantId: string): Promise<Buffer | undefined> {
|
|
92
|
-
return this.map.get(tenantId);
|
|
174
|
+
return this.map.get(tenantId)?.dek;
|
|
93
175
|
}
|
|
94
176
|
async set(tenantId: string, dek: Buffer): Promise<void> {
|
|
95
|
-
this.map.
|
|
177
|
+
const prev = this.map.get(tenantId);
|
|
178
|
+
this.map.set(tenantId, {
|
|
179
|
+
dek: Buffer.from(dek),
|
|
180
|
+
version: prev?.version ?? 1,
|
|
181
|
+
uses: 0,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async getEntry(tenantId: string): Promise<DekEntry | undefined> {
|
|
185
|
+
const entry = this.map.get(tenantId);
|
|
186
|
+
return entry === undefined ? undefined : { ...entry, dek: Buffer.from(entry.dek) };
|
|
187
|
+
}
|
|
188
|
+
async setEntry(tenantId: string, entry: DekEntry): Promise<void> {
|
|
189
|
+
this.map.set(tenantId, { ...entry, dek: Buffer.from(entry.dek) });
|
|
96
190
|
}
|
|
191
|
+
async tenants(): Promise<ReadonlyArray<string>> {
|
|
192
|
+
return [...this.map.keys()];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Source of KEK material for {@link createFileDekStore}. The store wraps
|
|
198
|
+
* each DEK before it touches disk and unwraps on read, so it needs the
|
|
199
|
+
* *current* KEK to seal new writes and any *superseded* KEK (keyed by the
|
|
200
|
+
* `kekRef` recorded alongside the wrapped DEK) to open older files after
|
|
201
|
+
* a rotation. Operators construct this at boot from the same KEK(s) they
|
|
202
|
+
* re-provide to the engine — the store never persists the KEK value
|
|
203
|
+
* itself, only the wrapped DEK plus its `kekRef`.
|
|
204
|
+
*/
|
|
205
|
+
export interface KekProvider {
|
|
206
|
+
/** KEK used to wrap DEKs on write. */
|
|
207
|
+
current(): { readonly kekRef: string; readonly kekValue: string };
|
|
208
|
+
/** Resolve the KEK value a stored DEK was wrapped under, by `kekRef`. */
|
|
209
|
+
resolve(kekRef: string): string | undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build a {@link KekProvider} from a current KEK plus zero or more
|
|
214
|
+
* superseded KEKs (keyed by their original `kekRef`). After a rotation,
|
|
215
|
+
* the operator re-supplies the prior KEK(s) here so the file store can
|
|
216
|
+
* unwrap DEK files sealed under them.
|
|
217
|
+
*/
|
|
218
|
+
export function staticKekProvider(
|
|
219
|
+
current: { readonly kekRef: string; readonly kekValue: string },
|
|
220
|
+
retained: ReadonlyArray<{ readonly kekRef: string; readonly kekValue: string }> = [],
|
|
221
|
+
): KekProvider {
|
|
222
|
+
const byRef = new Map<string, string>();
|
|
223
|
+
for (const { kekRef, kekValue } of retained) byRef.set(kekRef, kekValue);
|
|
224
|
+
// The current KEK takes precedence over any same-ref retained entry.
|
|
225
|
+
byRef.set(current.kekRef, current.kekValue);
|
|
226
|
+
return {
|
|
227
|
+
current: () => ({ kekRef: current.kekRef, kekValue: current.kekValue }),
|
|
228
|
+
resolve: (kekRef) => byRef.get(kekRef),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** On-disk shape of a persisted DEK file. Never contains the raw DEK. */
|
|
233
|
+
type PersistedDek = {
|
|
234
|
+
readonly version: number;
|
|
235
|
+
readonly uses: number;
|
|
236
|
+
/** KEK ref the DEK is wrapped under — selects the unwrap key on read. */
|
|
237
|
+
readonly kekRef: string;
|
|
238
|
+
/** Per-file scrypt salt (hex) for the wrapping-key derivation. */
|
|
239
|
+
readonly kekSalt: string;
|
|
240
|
+
/** Wrapped (encrypted) DEK + GCM IV/tag, all hex. */
|
|
241
|
+
readonly wrappedDek: string;
|
|
242
|
+
readonly wrappedDekIv: string;
|
|
243
|
+
readonly wrappedDekTag: string;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export type FileDekStoreOptions = {
|
|
247
|
+
/**
|
|
248
|
+
* Test seam: deterministic IV/salt generator for the wrapping step.
|
|
249
|
+
* Defaults to {@link randomBytes}.
|
|
250
|
+
*/
|
|
251
|
+
readonly randomBytesImpl?: (n: number) => Buffer;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* File-backed {@link DekStore} that persists DEKs so they (and their
|
|
256
|
+
* version + use-count) survive a restart. Each tenant's DEK lives in
|
|
257
|
+
* `<rootDir>/dek-<tenant>.json` written at mode `0o600`.
|
|
258
|
+
*
|
|
259
|
+
* SECURITY: the raw DEK is **never** written to disk. It is wrapped with
|
|
260
|
+
* the {@link KekProvider}'s current KEK (scrypt-derived AES-256-GCM key,
|
|
261
|
+
* the same scheme the engine uses for records) and only the *wrapped*
|
|
262
|
+
* bytes — together with the `kekRef` and salt needed to re-derive the
|
|
263
|
+
* unwrapping key — are persisted. The KEK *value* is supplied by the
|
|
264
|
+
* operator at boot and is never persisted (CWE-312/CWE-256): an attacker
|
|
265
|
+
* with read access to `rootDir` gets only ciphertext.
|
|
266
|
+
*
|
|
267
|
+
* After a rotation the operator must keep providing the prior KEK(s) via
|
|
268
|
+
* {@link staticKekProvider}'s `retained` list until every tenant's file
|
|
269
|
+
* has been rewritten under the new KEK (which happens on the next write
|
|
270
|
+
* for that tenant, including the re-mint that `rotateKek` performs).
|
|
271
|
+
*/
|
|
272
|
+
export function createFileDekStore(
|
|
273
|
+
rootDir: string,
|
|
274
|
+
kek: KekProvider,
|
|
275
|
+
opts: FileDekStoreOptions = {},
|
|
276
|
+
): DekStore {
|
|
277
|
+
if (typeof rootDir !== "string" || rootDir.length === 0) {
|
|
278
|
+
throw new AuditEncryptionError("createFileDekStore: rootDir is required");
|
|
279
|
+
}
|
|
280
|
+
const rng = opts.randomBytesImpl ?? randomBytes;
|
|
281
|
+
mkdirSync(rootDir, { recursive: true, mode: 0o700 });
|
|
282
|
+
|
|
283
|
+
const FILE_PREFIX = "dek-";
|
|
284
|
+
const FILE_SUFFIX = ".json";
|
|
285
|
+
|
|
286
|
+
function pathFor(tenantId: string): string {
|
|
287
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(tenantId)) {
|
|
288
|
+
throw new AuditEncryptionError(
|
|
289
|
+
`createFileDekStore: invalid tenantId "${tenantId}" (must match [A-Za-z0-9_.-]+)`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return join(rootDir, `${FILE_PREFIX}${tenantId}${FILE_SUFFIX}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Wrap a raw DEK under the current KEK for persistence. */
|
|
296
|
+
function wrap(dek: Buffer): Omit<PersistedDek, "version" | "uses"> {
|
|
297
|
+
const { kekRef, kekValue } = kek.current();
|
|
298
|
+
const salt = rng(SALT_BYTES);
|
|
299
|
+
const kekKey = deriveKekKey(kekValue, salt);
|
|
300
|
+
const iv = rng(IV_BYTES);
|
|
301
|
+
const { ciphertext, tag } = encryptBytes(dek, kekKey, iv);
|
|
302
|
+
return {
|
|
303
|
+
kekRef,
|
|
304
|
+
kekSalt: salt.toString("hex"),
|
|
305
|
+
wrappedDek: ciphertext.toString("hex"),
|
|
306
|
+
wrappedDekIv: iv.toString("hex"),
|
|
307
|
+
wrappedDekTag: tag.toString("hex"),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Unwrap a persisted DEK using the KEK its `kekRef` selects. */
|
|
312
|
+
function unwrap(p: PersistedDek): Buffer {
|
|
313
|
+
const kekValue = kek.resolve(p.kekRef);
|
|
314
|
+
if (kekValue === undefined) {
|
|
315
|
+
throw new AuditEncryptionError(
|
|
316
|
+
`createFileDekStore: no KEK material for kekRef ${p.kekRef}; cannot unwrap persisted DEK (re-provide the prior KEK via staticKekProvider's retained list)`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const kekKey = deriveKekKey(kekValue, Buffer.from(p.kekSalt, "hex"));
|
|
320
|
+
return decryptBytes(
|
|
321
|
+
Buffer.from(p.wrappedDek, "hex"),
|
|
322
|
+
kekKey,
|
|
323
|
+
Buffer.from(p.wrappedDekIv, "hex"),
|
|
324
|
+
Buffer.from(p.wrappedDekTag, "hex"),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readPersisted(tenantId: string): PersistedDek | undefined {
|
|
329
|
+
const p = pathFor(tenantId);
|
|
330
|
+
if (!existsSync(p)) return undefined;
|
|
331
|
+
const raw = readFileSync(p, "utf8");
|
|
332
|
+
let parsed: PersistedDek;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(raw) as PersistedDek;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
throw new AuditEncryptionError(`createFileDekStore: corrupt DEK file at ${p}`, err);
|
|
337
|
+
}
|
|
338
|
+
return parsed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Atomic write at 0o600: write `.tmp`, then rename into place. */
|
|
342
|
+
function writePersisted(tenantId: string, value: PersistedDek): void {
|
|
343
|
+
const p = pathFor(tenantId);
|
|
344
|
+
const tmp = `${p}.tmp`;
|
|
345
|
+
writeFileSync(tmp, JSON.stringify(value), { encoding: "utf8", mode: 0o600 });
|
|
346
|
+
renameSync(tmp, p);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
async get(tenantId: string): Promise<Buffer | undefined> {
|
|
351
|
+
const p = readPersisted(tenantId);
|
|
352
|
+
return p === undefined ? undefined : unwrap(p);
|
|
353
|
+
},
|
|
354
|
+
async set(tenantId: string, dek: Buffer): Promise<void> {
|
|
355
|
+
const prev = readPersisted(tenantId);
|
|
356
|
+
writePersisted(tenantId, { ...wrap(dek), version: prev?.version ?? 1, uses: 0 });
|
|
357
|
+
},
|
|
358
|
+
async getEntry(tenantId: string): Promise<DekEntry | undefined> {
|
|
359
|
+
const p = readPersisted(tenantId);
|
|
360
|
+
if (p === undefined) return undefined;
|
|
361
|
+
return { dek: unwrap(p), version: p.version, uses: p.uses };
|
|
362
|
+
},
|
|
363
|
+
async setEntry(tenantId: string, entry: DekEntry): Promise<void> {
|
|
364
|
+
writePersisted(tenantId, {
|
|
365
|
+
...wrap(entry.dek),
|
|
366
|
+
version: entry.version,
|
|
367
|
+
uses: entry.uses,
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
async tenants(): Promise<ReadonlyArray<string>> {
|
|
371
|
+
if (!existsSync(rootDir)) return [];
|
|
372
|
+
return readdirSync(rootDir)
|
|
373
|
+
.filter((f) => f.startsWith(FILE_PREFIX) && f.endsWith(FILE_SUFFIX))
|
|
374
|
+
.map((f) => f.slice(FILE_PREFIX.length, f.length - FILE_SUFFIX.length));
|
|
375
|
+
},
|
|
376
|
+
};
|
|
97
377
|
}
|
|
98
378
|
|
|
99
379
|
const KEY_BYTES = 32; // AES-256
|
|
100
380
|
const IV_BYTES = 12; // GCM standard
|
|
381
|
+
const SALT_BYTES = 16; // scrypt salt
|
|
382
|
+
/** scrypt cost params: N=2^15 keeps derivation well under a frame budget. */
|
|
383
|
+
const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } as const;
|
|
384
|
+
/** Default DEK roll threshold. */
|
|
385
|
+
export const DEFAULT_MAX_RECORDS_PER_DEK = 100_000;
|
|
101
386
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Derive the 32-byte AES wrapping key from the KEK string using scrypt
|
|
389
|
+
* with the supplied salt. scrypt is salted + memory-hard, so this is
|
|
390
|
+
* sound even when `kekValue` is a low-entropy passphrase (CWE-916). The
|
|
391
|
+
* salt must be persisted (`EncryptedRecord.kekSalt`) to re-derive.
|
|
392
|
+
*/
|
|
393
|
+
function deriveKekKey(kekValue: string, salt: Buffer): Buffer {
|
|
394
|
+
return scryptSync(kekValue, salt, KEY_BYTES, SCRYPT_PARAMS);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Legacy unsalted-SHA-256 derivation. Retained only to unwrap records
|
|
399
|
+
* written before the scrypt migration (those carry no `kekSalt`). Never
|
|
400
|
+
* used for new records.
|
|
401
|
+
*/
|
|
402
|
+
function deriveKekKeyLegacy(kekValue: string): Buffer {
|
|
106
403
|
return createHash("sha256").update(kekValue).digest();
|
|
107
404
|
}
|
|
108
405
|
|
|
@@ -129,8 +426,11 @@ export interface AuditEncryption {
|
|
|
129
426
|
/** Decrypt and parse a previously encrypted record. */
|
|
130
427
|
decryptPayload(record: EncryptedRecord): Promise<unknown>;
|
|
131
428
|
/**
|
|
132
|
-
*
|
|
133
|
-
* subscribe via `secrets.onRotation(handler)` and
|
|
429
|
+
* Adopt a new KEK and re-key every tenant's DEK to a fresh version.
|
|
430
|
+
* Production callers subscribe via `secrets.onRotation(handler)` and
|
|
431
|
+
* forward to this. The prior KEK value is retained in-process so
|
|
432
|
+
* historical records (which keep their original `kekRef`) still
|
|
433
|
+
* decrypt.
|
|
134
434
|
*/
|
|
135
435
|
rotateKek(newKekValue: string, newKekRef: string): Promise<void>;
|
|
136
436
|
/** Current KEK ref. */
|
|
@@ -148,35 +448,112 @@ export async function createAuditEncryption(
|
|
|
148
448
|
}
|
|
149
449
|
const dekStore = opts.dekStore ?? new InMemoryDekStore();
|
|
150
450
|
const rng = opts.randomBytesImpl ?? randomBytes;
|
|
451
|
+
const maxRecordsPerDek =
|
|
452
|
+
opts.maxRecordsPerDek !== undefined && opts.maxRecordsPerDek > 0
|
|
453
|
+
? opts.maxRecordsPerDek
|
|
454
|
+
: DEFAULT_MAX_RECORDS_PER_DEK;
|
|
151
455
|
const initialKekValue = await opts.secrets.get(opts.kekName);
|
|
152
|
-
let currentKekRef =
|
|
153
|
-
|
|
456
|
+
let currentKekRef =
|
|
457
|
+
typeof opts.kekRef === "string" && opts.kekRef.length > 0
|
|
458
|
+
? opts.kekRef
|
|
459
|
+
: `kek:${opts.kekName}:v1`;
|
|
460
|
+
let currentKekValue = initialKekValue;
|
|
461
|
+
// Retain every KEK value we have ever held, keyed by its ref, so
|
|
462
|
+
// `decryptPayload` can re-derive the wrapping key for records sealed
|
|
463
|
+
// under a now-superseded KEK (CWE-323). Production deployments that
|
|
464
|
+
// restart rehydrate the superseded entries from `retainedKeks` (the
|
|
465
|
+
// secret backend's history), since the registry is otherwise
|
|
466
|
+
// process-local and lost across restarts.
|
|
467
|
+
const kekValuesByRef = new Map<string, string>([[currentKekRef, currentKekValue]]);
|
|
468
|
+
for (const { kekRef, kekValue } of opts.retainedKeks ?? []) {
|
|
469
|
+
// The boot KEK already occupies `currentKekRef`; don't let a stale
|
|
470
|
+
// retained entry shadow it.
|
|
471
|
+
if (!kekValuesByRef.has(kekRef)) {
|
|
472
|
+
kekValuesByRef.set(kekRef, kekValue);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
154
475
|
|
|
155
|
-
// Auto-subscribe to rotation events.
|
|
476
|
+
// Auto-subscribe to rotation events. The re-key runs fire-and-forget, but
|
|
477
|
+
// its rejection is contained locally: a failed event-driven rotation must
|
|
478
|
+
// never escape as an unhandled rejection (which could crash the host
|
|
479
|
+
// process). The engine simply keeps its last-good KEK state and historical
|
|
480
|
+
// records still decrypt.
|
|
156
481
|
const unsubscribeRotation = opts.secrets.onRotation((event) => {
|
|
157
482
|
if (event.name !== opts.kekName) return;
|
|
158
|
-
void rotateInternal(event.newValue, `kek:${opts.kekName}:${event.rotatedAt}`)
|
|
483
|
+
void rotateInternal(event.newValue, `kek:${opts.kekName}:${event.rotatedAt}`).catch(() => {
|
|
484
|
+
/* contained — see comment above */
|
|
485
|
+
});
|
|
159
486
|
});
|
|
160
487
|
// Suppress unused-variable warning — unsubscribeRotation is intended
|
|
161
488
|
// for future shutdown plumbing; tests can ignore it.
|
|
162
489
|
void unsubscribeRotation;
|
|
163
490
|
|
|
491
|
+
async function readEntry(tenantId: string): Promise<DekEntry | undefined> {
|
|
492
|
+
if (dekStore.getEntry !== undefined) {
|
|
493
|
+
return dekStore.getEntry(tenantId);
|
|
494
|
+
}
|
|
495
|
+
const dek = await dekStore.get(tenantId);
|
|
496
|
+
if (dek === undefined || dek.length !== KEY_BYTES) return undefined;
|
|
497
|
+
return { dek, version: 1, uses: 0 };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function writeEntry(tenantId: string, entry: DekEntry): Promise<void> {
|
|
501
|
+
if (dekStore.setEntry !== undefined) {
|
|
502
|
+
await dekStore.setEntry(tenantId, entry);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
await dekStore.set(tenantId, entry.dek);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function mintDek(tenantId: string, version: number): DekEntry {
|
|
509
|
+
return { dek: rng(KEY_BYTES), version, uses: 0 };
|
|
510
|
+
}
|
|
511
|
+
|
|
164
512
|
async function getOrCreateDek(tenantId: string): Promise<{ dek: Buffer; dekRef: string }> {
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
513
|
+
let entry = await readEntry(tenantId);
|
|
514
|
+
if (entry === undefined) {
|
|
515
|
+
entry = mintDek(tenantId, 1);
|
|
516
|
+
} else if (entry.uses >= maxRecordsPerDek) {
|
|
517
|
+
// Roll to a fresh DEK version once the current one is exhausted.
|
|
518
|
+
entry = mintDek(tenantId, entry.version + 1);
|
|
168
519
|
}
|
|
169
|
-
const
|
|
170
|
-
await
|
|
171
|
-
return { dek, dekRef: `dek:${tenantId}:
|
|
520
|
+
const next: DekEntry = { dek: entry.dek, version: entry.version, uses: entry.uses + 1 };
|
|
521
|
+
await writeEntry(tenantId, next);
|
|
522
|
+
return { dek: next.dek, dekRef: `dek:${tenantId}:v${next.version}` };
|
|
172
523
|
}
|
|
173
524
|
|
|
174
525
|
async function rotateInternal(newKekValue: string, newKekRef: string): Promise<void> {
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
526
|
+
// Retain the prior KEK value so historical records keep decrypting,
|
|
527
|
+
// then adopt the new one as current.
|
|
528
|
+
kekValuesByRef.set(newKekRef, newKekValue);
|
|
529
|
+
currentKekValue = newKekValue;
|
|
179
530
|
currentKekRef = newKekRef;
|
|
531
|
+
// Re-key every tenant's DEK to a fresh version. Records already on
|
|
532
|
+
// disk keep their old `dekRef`/`kekRef`; subsequent writes use the
|
|
533
|
+
// new DEK version wrapped under the new KEK.
|
|
534
|
+
if (dekStore.tenants !== undefined) {
|
|
535
|
+
const tenants = await dekStore.tenants();
|
|
536
|
+
for (const tenantId of tenants) {
|
|
537
|
+
const entry = await readEntry(tenantId);
|
|
538
|
+
if (entry === undefined) continue;
|
|
539
|
+
await writeEntry(tenantId, mintDek(tenantId, entry.version + 1));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function deriveForRef(kekRef: string, kekSalt: string | undefined): Buffer {
|
|
545
|
+
const kekValue = kekValuesByRef.get(kekRef);
|
|
546
|
+
if (kekValue === undefined) {
|
|
547
|
+
throw new AuditEncryptionError(
|
|
548
|
+
`no KEK material retained for kekRef ${kekRef}; cannot unwrap DEK`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
// Legacy records (pre-KDF migration) carry no salt — fall back to the
|
|
552
|
+
// unsalted derivation that originally sealed them.
|
|
553
|
+
if (kekSalt === undefined) {
|
|
554
|
+
return deriveKekKeyLegacy(kekValue);
|
|
555
|
+
}
|
|
556
|
+
return deriveKekKey(kekValue, Buffer.from(kekSalt, "hex"));
|
|
180
557
|
}
|
|
181
558
|
|
|
182
559
|
return {
|
|
@@ -191,16 +568,20 @@ export async function createAuditEncryption(
|
|
|
191
568
|
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
192
569
|
const iv = rng(IV_BYTES);
|
|
193
570
|
const { ciphertext, tag } = encryptBytes(plaintext, dek, iv);
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
571
|
+
// Derive the wrapping key with a fresh per-record salt, then wrap
|
|
572
|
+
// the DEK with the current KEK so we can persist the wrapped form
|
|
573
|
+
// alongside the record (production callers may store the wrapped
|
|
574
|
+
// DEK out-of-band; we include it here for self-contained
|
|
197
575
|
// round-trip).
|
|
576
|
+
const salt = rng(SALT_BYTES);
|
|
577
|
+
const kekKey = deriveKekKey(currentKekValue, salt);
|
|
198
578
|
const dekIv = rng(IV_BYTES);
|
|
199
|
-
const { ciphertext: wrappedDek, tag: wrappedTag } = encryptBytes(dek,
|
|
579
|
+
const { ciphertext: wrappedDek, tag: wrappedTag } = encryptBytes(dek, kekKey, dekIv);
|
|
200
580
|
return {
|
|
201
581
|
tenantId,
|
|
202
582
|
kekRef: currentKekRef,
|
|
203
583
|
dekRef,
|
|
584
|
+
kekSalt: salt.toString("hex"),
|
|
204
585
|
iv: iv.toString("hex"),
|
|
205
586
|
tag: tag.toString("hex"),
|
|
206
587
|
encryptedPayload: ciphertext.toString("hex"),
|
|
@@ -210,10 +591,12 @@ export async function createAuditEncryption(
|
|
|
210
591
|
};
|
|
211
592
|
},
|
|
212
593
|
async decryptPayload(record: EncryptedRecord): Promise<unknown> {
|
|
213
|
-
//
|
|
594
|
+
// Select the unwrapping KEK by the record's own `kekRef` so records
|
|
595
|
+
// sealed under a superseded KEK still decrypt after rotation.
|
|
596
|
+
const kekKey = deriveForRef(record.kekRef, record.kekSalt);
|
|
214
597
|
const dek = decryptBytes(
|
|
215
598
|
Buffer.from(record.wrappedDek, "hex"),
|
|
216
|
-
|
|
599
|
+
kekKey,
|
|
217
600
|
Buffer.from(record.wrappedDekIv, "hex"),
|
|
218
601
|
Buffer.from(record.wrappedDekTag, "hex"),
|
|
219
602
|
);
|
|
@@ -239,4 +622,5 @@ export {
|
|
|
239
622
|
encryptBytes as _encryptBytesForTest,
|
|
240
623
|
decryptBytes as _decryptBytesForTest,
|
|
241
624
|
deriveKekKey as _deriveKekKeyForTest,
|
|
625
|
+
deriveKekKeyLegacy as _deriveKekKeyLegacyForTest,
|
|
242
626
|
};
|