@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/dist/index.d.ts +247 -0
- package/dist/index.js +398 -0
- package/package.json +11 -8
- package/src/index.test.ts +0 -831
- package/src/index.ts +0 -626
package/src/index.test.ts
DELETED
|
@@ -1,831 +0,0 @@
|
|
|
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";
|
|
6
|
-
import { createEnvVarBackend, createSecrets } from "@crewhaus/secrets-manager";
|
|
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";
|
|
19
|
-
|
|
20
|
-
function setKek(name: string, value: string): void {
|
|
21
|
-
process.env[name] = value;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function buildEncryption(kekValue = "ke-secret-1234567890") {
|
|
25
|
-
setKek("KEK_TEST", kekValue);
|
|
26
|
-
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
27
|
-
return createAuditEncryption({ secrets, kekName: "KEK_TEST" });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("createAuditEncryption (T1 + T2)", () => {
|
|
31
|
-
test("encrypt + decrypt round-trip", async () => {
|
|
32
|
-
const enc = await buildEncryption();
|
|
33
|
-
const record = await enc.encryptPayload(
|
|
34
|
-
{ event: "policy_decision", verdict: "allow" },
|
|
35
|
-
"tenant-a",
|
|
36
|
-
);
|
|
37
|
-
expect(record.tenantId).toBe("tenant-a");
|
|
38
|
-
expect(record.encryptedPayload).toMatch(/^[a-f0-9]+$/);
|
|
39
|
-
expect(record.iv).toMatch(/^[a-f0-9]{24}$/);
|
|
40
|
-
expect(record.tag).toMatch(/^[a-f0-9]{32}$/);
|
|
41
|
-
const decoded = await enc.decryptPayload(record);
|
|
42
|
-
expect(decoded).toEqual({ event: "policy_decision", verdict: "allow" });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("encryption is non-deterministic (fresh IV per record)", async () => {
|
|
46
|
-
const enc = await buildEncryption();
|
|
47
|
-
const a = await enc.encryptPayload({ event: "x" }, "tenant-a");
|
|
48
|
-
const b = await enc.encryptPayload({ event: "x" }, "tenant-a");
|
|
49
|
-
expect(a.iv).not.toBe(b.iv);
|
|
50
|
-
expect(a.encryptedPayload).not.toBe(b.encryptedPayload);
|
|
51
|
-
// Both decrypt back to the same value.
|
|
52
|
-
expect(await enc.decryptPayload(a)).toEqual({ event: "x" });
|
|
53
|
-
expect(await enc.decryptPayload(b)).toEqual({ event: "x" });
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("requires kekName", async () => {
|
|
57
|
-
setKek("KEK_TEST", "v");
|
|
58
|
-
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
59
|
-
await expect(createAuditEncryption({ secrets, kekName: "" })).rejects.toThrow(
|
|
60
|
-
/kekName is required/,
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("requires secrets", async () => {
|
|
65
|
-
await expect(
|
|
66
|
-
createAuditEncryption({
|
|
67
|
-
secrets: undefined as unknown as ReturnType<typeof createSecrets>,
|
|
68
|
-
kekName: "x",
|
|
69
|
-
}),
|
|
70
|
-
).rejects.toThrow(/secrets is required/);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("Per-tenant isolation", () => {
|
|
75
|
-
test("DEK differs per tenant — same plaintext encrypts differently", async () => {
|
|
76
|
-
const enc = await buildEncryption();
|
|
77
|
-
const a = await enc.encryptPayload({ event: "x" }, "tenant-a");
|
|
78
|
-
const b = await enc.encryptPayload({ event: "x" }, "tenant-b");
|
|
79
|
-
// Different DEK means different wrapped DEK + different ciphertext.
|
|
80
|
-
expect(a.wrappedDek).not.toBe(b.wrappedDek);
|
|
81
|
-
expect(a.encryptedPayload).not.toBe(b.encryptedPayload);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("missing tenantId throws AuditEncryptionError", async () => {
|
|
85
|
-
const enc = await buildEncryption();
|
|
86
|
-
await expect(enc.encryptPayload({ x: 1 }, "")).rejects.toThrow(AuditEncryptionError);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("T8 — tampered ciphertext detection", () => {
|
|
91
|
-
test("flipping a byte in encryptedPayload causes decrypt to throw", async () => {
|
|
92
|
-
const enc = await buildEncryption();
|
|
93
|
-
const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
|
|
94
|
-
// Flip the first byte of the ciphertext.
|
|
95
|
-
const tampered = {
|
|
96
|
-
...record,
|
|
97
|
-
encryptedPayload: (() => {
|
|
98
|
-
const buf = Buffer.from(record.encryptedPayload, "hex");
|
|
99
|
-
buf[0] = (buf[0] ?? 0) ^ 0xff;
|
|
100
|
-
return buf.toString("hex");
|
|
101
|
-
})(),
|
|
102
|
-
};
|
|
103
|
-
await expect(enc.decryptPayload(tampered)).rejects.toThrow();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("flipping the auth tag causes decrypt to throw", async () => {
|
|
107
|
-
const enc = await buildEncryption();
|
|
108
|
-
const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
|
|
109
|
-
const tampered = {
|
|
110
|
-
...record,
|
|
111
|
-
tag: (() => {
|
|
112
|
-
const buf = Buffer.from(record.tag, "hex");
|
|
113
|
-
buf[0] = (buf[0] ?? 0) ^ 0xff;
|
|
114
|
-
return buf.toString("hex");
|
|
115
|
-
})(),
|
|
116
|
-
};
|
|
117
|
-
await expect(enc.decryptPayload(tampered)).rejects.toThrow();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("flipping the IV causes decrypt to throw", async () => {
|
|
121
|
-
const enc = await buildEncryption();
|
|
122
|
-
const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
|
|
123
|
-
const tampered = {
|
|
124
|
-
...record,
|
|
125
|
-
iv: (() => {
|
|
126
|
-
const buf = Buffer.from(record.iv, "hex");
|
|
127
|
-
buf[0] = (buf[0] ?? 0) ^ 0xff;
|
|
128
|
-
return buf.toString("hex");
|
|
129
|
-
})(),
|
|
130
|
-
};
|
|
131
|
-
await expect(enc.decryptPayload(tampered)).rejects.toThrow();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("flipping the wrapped DEK causes decrypt to throw", async () => {
|
|
135
|
-
const enc = await buildEncryption();
|
|
136
|
-
const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
|
|
137
|
-
const tampered = {
|
|
138
|
-
...record,
|
|
139
|
-
wrappedDek: (() => {
|
|
140
|
-
const buf = Buffer.from(record.wrappedDek, "hex");
|
|
141
|
-
buf[0] = (buf[0] ?? 0) ^ 0xff;
|
|
142
|
-
return buf.toString("hex");
|
|
143
|
-
})(),
|
|
144
|
-
};
|
|
145
|
-
await expect(enc.decryptPayload(tampered)).rejects.toThrow();
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("KEK rotation", () => {
|
|
150
|
-
test("rotateKek with new value lets new records encrypt+decrypt", async () => {
|
|
151
|
-
const enc = await buildEncryption("kek-v1-secret-12345678");
|
|
152
|
-
await enc.rotateKek("kek-v2-secret-87654321", "kek:KEK_TEST:v2");
|
|
153
|
-
expect(enc.kekRef).toBe("kek:KEK_TEST:v2");
|
|
154
|
-
const r = await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
155
|
-
expect(r.kekRef).toBe("kek:KEK_TEST:v2");
|
|
156
|
-
const d = await enc.decryptPayload(r);
|
|
157
|
-
expect(d).toEqual({ x: 1 });
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe("DekStore plumbing", () => {
|
|
162
|
-
test("custom DekStore is used for DEK persistence", async () => {
|
|
163
|
-
setKek("KEK_TEST", "kek-secret-12345678");
|
|
164
|
-
const secrets = createSecrets({ backend: createEnvVarBackend() });
|
|
165
|
-
const store = new InMemoryDekStore();
|
|
166
|
-
const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
|
|
167
|
-
await enc.encryptPayload({ x: 1 }, "tenant-a");
|
|
168
|
-
expect(await store.get("tenant-a")).toBeDefined();
|
|
169
|
-
expect((await store.get("tenant-a"))?.length).toBe(32);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("InMemoryDekStore round-trip", async () => {
|
|
173
|
-
const store = new InMemoryDekStore();
|
|
174
|
-
expect(await store.get("missing")).toBeUndefined();
|
|
175
|
-
const buf = Buffer.from("a".repeat(32));
|
|
176
|
-
await store.set("tenant-a", buf);
|
|
177
|
-
const out = await store.get("tenant-a");
|
|
178
|
-
expect(out).toBeDefined();
|
|
179
|
-
expect(out?.length).toBe(32);
|
|
180
|
-
});
|
|
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
|
-
});
|