@crewhaus/audit-encryption 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,626 +0,0 @@
1
- import {
2
- type CipherGCM,
3
- type DecipherGCM,
4
- createCipheriv,
5
- createDecipheriv,
6
- createHash,
7
- randomBytes,
8
- scryptSync,
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";
19
- import { CrewhausError } from "@crewhaus/errors";
20
- import type { Secrets } from "@crewhaus/secrets-manager";
21
-
22
- /**
23
- * Catalog R17 `audit-encryption` — Section 39 envelope encryption for
24
- * audit-log payloads.
25
- *
26
- * Encrypts per-record JSON payloads with a tenant-scoped Data
27
- * Encryption Key (DEK); the DEK itself is encrypted ("wrapped") with
28
- * a Key Encryption Key (KEK) sourced from §27 `secrets-manager`. The
29
- * resulting record carries
30
- * { tenantId, kekRef, dekRef, kekSalt, iv, tag, encryptedPayload, ... }
31
- * and is verifiable + decryptable by any caller with the same KEK.
32
- *
33
- * Algorithms:
34
- * - AES-256-GCM for both DEK→payload and KEK→DEK wrapping. GCM is
35
- * authenticated, so tampering with `encryptedPayload`, `iv`, or
36
- * `tag` causes `decrypt` to throw — satisfies the §39 T8
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.
45
- * - 12-byte (96-bit) IVs randomly generated per record.
46
- *
47
- * Key rotation:
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.
57
- *
58
- * Layer R17. Pairs with `audit-log` (R-infra — wraps `append` /
59
- * `read`) and `secrets-manager` (§27 — KEK source).
60
- */
61
-
62
- export class AuditEncryptionError extends CrewhausError {
63
- override readonly name = "AuditEncryptionError";
64
- constructor(message: string, cause?: unknown) {
65
- super("config", message, cause);
66
- }
67
- }
68
-
69
- export type EncryptedRecord = {
70
- /** Tenant whose DEK was used. */
71
- readonly tenantId: string;
72
- /** Stable identifier for the KEK version used to wrap the DEK. */
73
- readonly kekRef: string;
74
- /** Stable identifier for the DEK used to encrypt the payload. */
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;
82
- /** 96-bit GCM IV (24 hex chars). */
83
- readonly iv: string;
84
- /** 128-bit GCM auth tag (32 hex chars). */
85
- readonly tag: string;
86
- /** Encrypted payload (hex). */
87
- readonly encryptedPayload: string;
88
- /** Wrapped DEK (hex), sealed with `kekRef`. */
89
- readonly wrappedDek: string;
90
- /** Wrapped DEK IV (hex). */
91
- readonly wrappedDekIv: string;
92
- /** Wrapped DEK auth tag (hex). */
93
- readonly wrappedDekTag: string;
94
- };
95
-
96
- export type AuditEncryptionOptions = {
97
- readonly secrets: Secrets;
98
- /** Name of the KEK in §27 secrets-manager. */
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;
110
- /**
111
- * Optional persistent DEK store. If omitted, DEKs live in-memory
112
- * (process-local). Production should plug a tenant-scoped key store
113
- * here (HSM, KMS, vault path) — see {@link createFileDekStore} for a
114
- * file-backed implementation that survives restart.
115
- */
116
- readonly dekStore?: DekStore;
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. */
135
- readonly randomBytesImpl?: (n: number) => Buffer;
136
- /** Test seam: synthetic Date.now. */
137
- readonly now?: () => number;
138
- };
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
-
152
- export interface DekStore {
153
- get(tenantId: string): Promise<Buffer | undefined>;
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>>;
166
- }
167
-
168
- export class InMemoryDekStore implements DekStore {
169
- private readonly map: Map<string, DekEntry>;
170
- constructor() {
171
- this.map = new Map<string, DekEntry>();
172
- }
173
- async get(tenantId: string): Promise<Buffer | undefined> {
174
- return this.map.get(tenantId)?.dek;
175
- }
176
- async set(tenantId: string, dek: Buffer): Promise<void> {
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) });
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
- };
377
- }
378
-
379
- const KEY_BYTES = 32; // AES-256
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;
386
-
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 {
403
- return createHash("sha256").update(kekValue).digest();
404
- }
405
-
406
- function encryptBytes(
407
- plaintext: Buffer,
408
- key: Buffer,
409
- iv: Buffer,
410
- ): { ciphertext: Buffer; tag: Buffer } {
411
- const cipher: CipherGCM = createCipheriv("aes-256-gcm", key, iv);
412
- const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
413
- const tag = cipher.getAuthTag();
414
- return { ciphertext, tag };
415
- }
416
-
417
- function decryptBytes(ciphertext: Buffer, key: Buffer, iv: Buffer, tag: Buffer): Buffer {
418
- const decipher: DecipherGCM = createDecipheriv("aes-256-gcm", key, iv);
419
- decipher.setAuthTag(tag);
420
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
421
- }
422
-
423
- export interface AuditEncryption {
424
- /** Encrypt the JSON-serializable payload for this tenant. */
425
- encryptPayload(payload: unknown, tenantId: string): Promise<EncryptedRecord>;
426
- /** Decrypt and parse a previously encrypted record. */
427
- decryptPayload(record: EncryptedRecord): Promise<unknown>;
428
- /**
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.
434
- */
435
- rotateKek(newKekValue: string, newKekRef: string): Promise<void>;
436
- /** Current KEK ref. */
437
- readonly kekRef: string;
438
- }
439
-
440
- export async function createAuditEncryption(
441
- opts: AuditEncryptionOptions,
442
- ): Promise<AuditEncryption> {
443
- if (typeof opts.kekName !== "string" || opts.kekName.length === 0) {
444
- throw new AuditEncryptionError("kekName is required");
445
- }
446
- if (opts.secrets === undefined) {
447
- throw new AuditEncryptionError("secrets is required");
448
- }
449
- const dekStore = opts.dekStore ?? new InMemoryDekStore();
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;
455
- const initialKekValue = await opts.secrets.get(opts.kekName);
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
- }
475
-
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.
481
- const unsubscribeRotation = opts.secrets.onRotation((event) => {
482
- if (event.name !== opts.kekName) return;
483
- void rotateInternal(event.newValue, `kek:${opts.kekName}:${event.rotatedAt}`).catch(() => {
484
- /* contained — see comment above */
485
- });
486
- });
487
- // Suppress unused-variable warning — unsubscribeRotation is intended
488
- // for future shutdown plumbing; tests can ignore it.
489
- void unsubscribeRotation;
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
-
512
- async function getOrCreateDek(tenantId: string): Promise<{ dek: Buffer; dekRef: string }> {
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);
519
- }
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}` };
523
- }
524
-
525
- async function rotateInternal(newKekValue: string, newKekRef: string): Promise<void> {
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;
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"));
557
- }
558
-
559
- return {
560
- get kekRef(): string {
561
- return currentKekRef;
562
- },
563
- async encryptPayload(payload: unknown, tenantId: string): Promise<EncryptedRecord> {
564
- if (typeof tenantId !== "string" || tenantId.length === 0) {
565
- throw new AuditEncryptionError("tenantId is required");
566
- }
567
- const { dek, dekRef } = await getOrCreateDek(tenantId);
568
- const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
569
- const iv = rng(IV_BYTES);
570
- const { ciphertext, tag } = encryptBytes(plaintext, dek, iv);
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
575
- // round-trip).
576
- const salt = rng(SALT_BYTES);
577
- const kekKey = deriveKekKey(currentKekValue, salt);
578
- const dekIv = rng(IV_BYTES);
579
- const { ciphertext: wrappedDek, tag: wrappedTag } = encryptBytes(dek, kekKey, dekIv);
580
- return {
581
- tenantId,
582
- kekRef: currentKekRef,
583
- dekRef,
584
- kekSalt: salt.toString("hex"),
585
- iv: iv.toString("hex"),
586
- tag: tag.toString("hex"),
587
- encryptedPayload: ciphertext.toString("hex"),
588
- wrappedDek: wrappedDek.toString("hex"),
589
- wrappedDekIv: dekIv.toString("hex"),
590
- wrappedDekTag: wrappedTag.toString("hex"),
591
- };
592
- },
593
- async decryptPayload(record: EncryptedRecord): Promise<unknown> {
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);
597
- const dek = decryptBytes(
598
- Buffer.from(record.wrappedDek, "hex"),
599
- kekKey,
600
- Buffer.from(record.wrappedDekIv, "hex"),
601
- Buffer.from(record.wrappedDekTag, "hex"),
602
- );
603
- const plaintext = decryptBytes(
604
- Buffer.from(record.encryptedPayload, "hex"),
605
- dek,
606
- Buffer.from(record.iv, "hex"),
607
- Buffer.from(record.tag, "hex"),
608
- );
609
- try {
610
- return JSON.parse(plaintext.toString("utf8"));
611
- } catch (err) {
612
- throw new AuditEncryptionError("decrypted payload is not valid JSON", err);
613
- }
614
- },
615
- async rotateKek(newKekValue: string, newKekRef: string): Promise<void> {
616
- await rotateInternal(newKekValue, newKekRef);
617
- },
618
- };
619
- }
620
-
621
- export {
622
- encryptBytes as _encryptBytesForTest,
623
- decryptBytes as _decryptBytesForTest,
624
- deriveKekKey as _deriveKekKeyForTest,
625
- deriveKekKeyLegacy as _deriveKekKeyLegacyForTest,
626
- };