@crewhaus/audit-encryption 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/audit-encryption",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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.1",
16
- "@crewhaus/errors": "0.1.1",
17
- "@crewhaus/secrets-manager": "0.1.1"
15
+ "@crewhaus/audit-log": "0.1.2",
16
+ "@crewhaus/errors": "0.1.2",
17
+ "@crewhaus/secrets-manager": "0.1.2"
18
18
  },
19
19
  "license": "Apache-2.0",
20
20
  "author": {
21
21
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
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": "restricted"
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 { AuditEncryptionError, InMemoryDekStore, createAuditEncryption } from "./index";
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 re-wraps
32
- * every cached DEK with the new KEK. Already-written records keep
33
- * their `kekRef`, so old records still verify against the previous
34
- * KEK if the secret backend retains it.
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
- /** Test seam: deterministic IV generator. */
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 = new Map<string, Buffer>();
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.set(tenantId, Buffer.from(dek));
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
- function deriveKekKey(kekValue: string): Buffer {
103
- // KEK comes back from secrets-manager as a string. Derive a 32-byte
104
- // AES key via SHA-256 same shape regardless of whether the secret
105
- // backend stores raw bytes, base64, or a passphrase.
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
- * Re-wrap every cached DEK with the new KEK value. Production callers
133
- * subscribe via `secrets.onRotation(handler)` and forward to this.
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 = `kek:${opts.kekName}:v1`;
153
- let currentKekKey = deriveKekKey(initialKekValue);
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
- const existing = await dekStore.get(tenantId);
166
- if (existing !== undefined && existing.length === KEY_BYTES) {
167
- return { dek: existing, dekRef: `dek:${tenantId}:v1` };
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 dek = rng(KEY_BYTES);
170
- await dekStore.set(tenantId, dek);
171
- return { dek, dekRef: `dek:${tenantId}:v1` };
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
- // For now we just swap the active KEK; old records still verify
176
- // against the previous KEK if the secret backend retains it. Cached
177
- // DEKs are wrapped lazily on the next encryptPayload call.
178
- currentKekKey = deriveKekKey(newKekValue);
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
- // Wrap the DEK with the KEK so we can persist the wrapped form
195
- // alongside the record (production callers may store the
196
- // wrapped DEK out-of-band; we include it here for self-contained
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, currentKekKey, dekIv);
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
- // Unwrap the DEK first.
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
- currentKekKey,
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
  };