@crewhaus/audit-encryption 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/audit-encryption",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Envelope encryption for hash-chained audit records: per-tenant DEK wrapped by KEK from @crewhaus/secrets-manager (Section 39)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/audit-log": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0",
17
+ "@crewhaus/secrets-manager": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/audit-encryption"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/audit-encryption#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEnvVarBackend, createSecrets } from "@crewhaus/secrets-manager";
3
+ import { AuditEncryptionError, InMemoryDekStore, createAuditEncryption } from "./index";
4
+
5
+ function setKek(name: string, value: string): void {
6
+ process.env[name] = value;
7
+ }
8
+
9
+ async function buildEncryption(kekValue = "ke-secret-1234567890") {
10
+ setKek("KEK_TEST", kekValue);
11
+ const secrets = createSecrets({ backend: createEnvVarBackend() });
12
+ return createAuditEncryption({ secrets, kekName: "KEK_TEST" });
13
+ }
14
+
15
+ describe("createAuditEncryption (T1 + T2)", () => {
16
+ test("encrypt + decrypt round-trip", async () => {
17
+ const enc = await buildEncryption();
18
+ const record = await enc.encryptPayload(
19
+ { event: "policy_decision", verdict: "allow" },
20
+ "tenant-a",
21
+ );
22
+ expect(record.tenantId).toBe("tenant-a");
23
+ expect(record.encryptedPayload).toMatch(/^[a-f0-9]+$/);
24
+ expect(record.iv).toMatch(/^[a-f0-9]{24}$/);
25
+ expect(record.tag).toMatch(/^[a-f0-9]{32}$/);
26
+ const decoded = await enc.decryptPayload(record);
27
+ expect(decoded).toEqual({ event: "policy_decision", verdict: "allow" });
28
+ });
29
+
30
+ test("encryption is non-deterministic (fresh IV per record)", async () => {
31
+ const enc = await buildEncryption();
32
+ const a = await enc.encryptPayload({ event: "x" }, "tenant-a");
33
+ const b = await enc.encryptPayload({ event: "x" }, "tenant-a");
34
+ expect(a.iv).not.toBe(b.iv);
35
+ expect(a.encryptedPayload).not.toBe(b.encryptedPayload);
36
+ // Both decrypt back to the same value.
37
+ expect(await enc.decryptPayload(a)).toEqual({ event: "x" });
38
+ expect(await enc.decryptPayload(b)).toEqual({ event: "x" });
39
+ });
40
+
41
+ test("requires kekName", async () => {
42
+ setKek("KEK_TEST", "v");
43
+ const secrets = createSecrets({ backend: createEnvVarBackend() });
44
+ await expect(createAuditEncryption({ secrets, kekName: "" })).rejects.toThrow(
45
+ /kekName is required/,
46
+ );
47
+ });
48
+
49
+ test("requires secrets", async () => {
50
+ await expect(
51
+ createAuditEncryption({
52
+ secrets: undefined as unknown as ReturnType<typeof createSecrets>,
53
+ kekName: "x",
54
+ }),
55
+ ).rejects.toThrow(/secrets is required/);
56
+ });
57
+ });
58
+
59
+ describe("Per-tenant isolation", () => {
60
+ test("DEK differs per tenant — same plaintext encrypts differently", async () => {
61
+ const enc = await buildEncryption();
62
+ const a = await enc.encryptPayload({ event: "x" }, "tenant-a");
63
+ const b = await enc.encryptPayload({ event: "x" }, "tenant-b");
64
+ // Different DEK means different wrapped DEK + different ciphertext.
65
+ expect(a.wrappedDek).not.toBe(b.wrappedDek);
66
+ expect(a.encryptedPayload).not.toBe(b.encryptedPayload);
67
+ });
68
+
69
+ test("missing tenantId throws AuditEncryptionError", async () => {
70
+ const enc = await buildEncryption();
71
+ await expect(enc.encryptPayload({ x: 1 }, "")).rejects.toThrow(AuditEncryptionError);
72
+ });
73
+ });
74
+
75
+ describe("T8 — tampered ciphertext detection", () => {
76
+ test("flipping a byte in encryptedPayload causes decrypt to throw", async () => {
77
+ const enc = await buildEncryption();
78
+ const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
79
+ // Flip the first byte of the ciphertext.
80
+ const tampered = {
81
+ ...record,
82
+ encryptedPayload: (() => {
83
+ const buf = Buffer.from(record.encryptedPayload, "hex");
84
+ buf[0] = (buf[0] ?? 0) ^ 0xff;
85
+ return buf.toString("hex");
86
+ })(),
87
+ };
88
+ await expect(enc.decryptPayload(tampered)).rejects.toThrow();
89
+ });
90
+
91
+ test("flipping the auth tag causes decrypt to throw", async () => {
92
+ const enc = await buildEncryption();
93
+ const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
94
+ const tampered = {
95
+ ...record,
96
+ tag: (() => {
97
+ const buf = Buffer.from(record.tag, "hex");
98
+ buf[0] = (buf[0] ?? 0) ^ 0xff;
99
+ return buf.toString("hex");
100
+ })(),
101
+ };
102
+ await expect(enc.decryptPayload(tampered)).rejects.toThrow();
103
+ });
104
+
105
+ test("flipping the IV causes decrypt to throw", async () => {
106
+ const enc = await buildEncryption();
107
+ const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
108
+ const tampered = {
109
+ ...record,
110
+ iv: (() => {
111
+ const buf = Buffer.from(record.iv, "hex");
112
+ buf[0] = (buf[0] ?? 0) ^ 0xff;
113
+ return buf.toString("hex");
114
+ })(),
115
+ };
116
+ await expect(enc.decryptPayload(tampered)).rejects.toThrow();
117
+ });
118
+
119
+ test("flipping the wrapped DEK causes decrypt to throw", async () => {
120
+ const enc = await buildEncryption();
121
+ const record = await enc.encryptPayload({ secret: "value" }, "tenant-a");
122
+ const tampered = {
123
+ ...record,
124
+ wrappedDek: (() => {
125
+ const buf = Buffer.from(record.wrappedDek, "hex");
126
+ buf[0] = (buf[0] ?? 0) ^ 0xff;
127
+ return buf.toString("hex");
128
+ })(),
129
+ };
130
+ await expect(enc.decryptPayload(tampered)).rejects.toThrow();
131
+ });
132
+ });
133
+
134
+ describe("KEK rotation", () => {
135
+ test("rotateKek with new value lets new records encrypt+decrypt", async () => {
136
+ const enc = await buildEncryption("kek-v1-secret-12345678");
137
+ await enc.rotateKek("kek-v2-secret-87654321", "kek:KEK_TEST:v2");
138
+ expect(enc.kekRef).toBe("kek:KEK_TEST:v2");
139
+ const r = await enc.encryptPayload({ x: 1 }, "tenant-a");
140
+ expect(r.kekRef).toBe("kek:KEK_TEST:v2");
141
+ const d = await enc.decryptPayload(r);
142
+ expect(d).toEqual({ x: 1 });
143
+ });
144
+ });
145
+
146
+ describe("DekStore plumbing", () => {
147
+ test("custom DekStore is used for DEK persistence", async () => {
148
+ setKek("KEK_TEST", "kek-secret-12345678");
149
+ const secrets = createSecrets({ backend: createEnvVarBackend() });
150
+ const store = new InMemoryDekStore();
151
+ const enc = await createAuditEncryption({ secrets, kekName: "KEK_TEST", dekStore: store });
152
+ await enc.encryptPayload({ x: 1 }, "tenant-a");
153
+ expect(await store.get("tenant-a")).toBeDefined();
154
+ expect((await store.get("tenant-a"))?.length).toBe(32);
155
+ });
156
+
157
+ test("InMemoryDekStore round-trip", async () => {
158
+ const store = new InMemoryDekStore();
159
+ expect(await store.get("missing")).toBeUndefined();
160
+ const buf = Buffer.from("a".repeat(32));
161
+ await store.set("tenant-a", buf);
162
+ const out = await store.get("tenant-a");
163
+ expect(out).toBeDefined();
164
+ expect(out?.length).toBe(32);
165
+ });
166
+ });
package/src/index.ts ADDED
@@ -0,0 +1,242 @@
1
+ import {
2
+ type CipherGCM,
3
+ type DecipherGCM,
4
+ createCipheriv,
5
+ createDecipheriv,
6
+ createHash,
7
+ randomBytes,
8
+ } from "node:crypto";
9
+ import { CrewhausError } from "@crewhaus/errors";
10
+ import type { Secrets } from "@crewhaus/secrets-manager";
11
+
12
+ /**
13
+ * Catalog R17 `audit-encryption` — Section 39 envelope encryption for
14
+ * audit-log payloads.
15
+ *
16
+ * Encrypts per-record JSON payloads with a tenant-scoped Data
17
+ * Encryption Key (DEK); the DEK itself is encrypted ("wrapped") with
18
+ * a Key Encryption Key (KEK) sourced from §27 `secrets-manager`. The
19
+ * resulting record carries
20
+ * { tenantId, kekRef, dekRef, iv, tag, encryptedPayload }
21
+ * and is verifiable + decryptable by any caller with the same KEK.
22
+ *
23
+ * Algorithms:
24
+ * - AES-256-GCM for both DEK→payload and KEK→DEK wrapping. GCM is
25
+ * authenticated, so tampering with `encryptedPayload`, `iv`, or
26
+ * `tag` causes `decrypt` to throw — satisfies the §39 T8
27
+ * ciphertext-integrity requirement.
28
+ * - 12-byte (96-bit) IVs randomly generated per record.
29
+ *
30
+ * 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.
35
+ *
36
+ * Layer R17. Pairs with `audit-log` (R-infra — wraps `append` /
37
+ * `read`) and `secrets-manager` (§27 — KEK source).
38
+ */
39
+
40
+ export class AuditEncryptionError extends CrewhausError {
41
+ override readonly name = "AuditEncryptionError";
42
+ constructor(message: string, cause?: unknown) {
43
+ super("config", message, cause);
44
+ }
45
+ }
46
+
47
+ export type EncryptedRecord = {
48
+ /** Tenant whose DEK was used. */
49
+ readonly tenantId: string;
50
+ /** Stable identifier for the KEK version used to wrap the DEK. */
51
+ readonly kekRef: string;
52
+ /** Stable identifier for the DEK used to encrypt the payload. */
53
+ readonly dekRef: string;
54
+ /** 96-bit GCM IV (24 hex chars). */
55
+ readonly iv: string;
56
+ /** 128-bit GCM auth tag (32 hex chars). */
57
+ readonly tag: string;
58
+ /** Encrypted payload (hex). */
59
+ readonly encryptedPayload: string;
60
+ /** Wrapped DEK (hex), sealed with `kekRef`. */
61
+ readonly wrappedDek: string;
62
+ /** Wrapped DEK IV (hex). */
63
+ readonly wrappedDekIv: string;
64
+ /** Wrapped DEK auth tag (hex). */
65
+ readonly wrappedDekTag: string;
66
+ };
67
+
68
+ export type AuditEncryptionOptions = {
69
+ readonly secrets: Secrets;
70
+ /** Name of the KEK in §27 secrets-manager. */
71
+ readonly kekName: string;
72
+ /**
73
+ * Optional persistent DEK store. If omitted, DEKs live in-memory
74
+ * (process-local). Production should plug a tenant-scoped key store
75
+ * here (HSM, KMS, vault path).
76
+ */
77
+ readonly dekStore?: DekStore;
78
+ /** Test seam: deterministic IV generator. */
79
+ readonly randomBytesImpl?: (n: number) => Buffer;
80
+ /** Test seam: synthetic Date.now. */
81
+ readonly now?: () => number;
82
+ };
83
+
84
+ export interface DekStore {
85
+ get(tenantId: string): Promise<Buffer | undefined>;
86
+ set(tenantId: string, dek: Buffer): Promise<void>;
87
+ }
88
+
89
+ export class InMemoryDekStore implements DekStore {
90
+ private readonly map = new Map<string, Buffer>();
91
+ async get(tenantId: string): Promise<Buffer | undefined> {
92
+ return this.map.get(tenantId);
93
+ }
94
+ async set(tenantId: string, dek: Buffer): Promise<void> {
95
+ this.map.set(tenantId, Buffer.from(dek));
96
+ }
97
+ }
98
+
99
+ const KEY_BYTES = 32; // AES-256
100
+ const IV_BYTES = 12; // GCM standard
101
+
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.
106
+ return createHash("sha256").update(kekValue).digest();
107
+ }
108
+
109
+ function encryptBytes(
110
+ plaintext: Buffer,
111
+ key: Buffer,
112
+ iv: Buffer,
113
+ ): { ciphertext: Buffer; tag: Buffer } {
114
+ const cipher: CipherGCM = createCipheriv("aes-256-gcm", key, iv);
115
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
116
+ const tag = cipher.getAuthTag();
117
+ return { ciphertext, tag };
118
+ }
119
+
120
+ function decryptBytes(ciphertext: Buffer, key: Buffer, iv: Buffer, tag: Buffer): Buffer {
121
+ const decipher: DecipherGCM = createDecipheriv("aes-256-gcm", key, iv);
122
+ decipher.setAuthTag(tag);
123
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
124
+ }
125
+
126
+ export interface AuditEncryption {
127
+ /** Encrypt the JSON-serializable payload for this tenant. */
128
+ encryptPayload(payload: unknown, tenantId: string): Promise<EncryptedRecord>;
129
+ /** Decrypt and parse a previously encrypted record. */
130
+ decryptPayload(record: EncryptedRecord): Promise<unknown>;
131
+ /**
132
+ * Re-wrap every cached DEK with the new KEK value. Production callers
133
+ * subscribe via `secrets.onRotation(handler)` and forward to this.
134
+ */
135
+ rotateKek(newKekValue: string, newKekRef: string): Promise<void>;
136
+ /** Current KEK ref. */
137
+ readonly kekRef: string;
138
+ }
139
+
140
+ export async function createAuditEncryption(
141
+ opts: AuditEncryptionOptions,
142
+ ): Promise<AuditEncryption> {
143
+ if (typeof opts.kekName !== "string" || opts.kekName.length === 0) {
144
+ throw new AuditEncryptionError("kekName is required");
145
+ }
146
+ if (opts.secrets === undefined) {
147
+ throw new AuditEncryptionError("secrets is required");
148
+ }
149
+ const dekStore = opts.dekStore ?? new InMemoryDekStore();
150
+ const rng = opts.randomBytesImpl ?? randomBytes;
151
+ const initialKekValue = await opts.secrets.get(opts.kekName);
152
+ let currentKekRef = `kek:${opts.kekName}:v1`;
153
+ let currentKekKey = deriveKekKey(initialKekValue);
154
+
155
+ // Auto-subscribe to rotation events.
156
+ const unsubscribeRotation = opts.secrets.onRotation((event) => {
157
+ if (event.name !== opts.kekName) return;
158
+ void rotateInternal(event.newValue, `kek:${opts.kekName}:${event.rotatedAt}`);
159
+ });
160
+ // Suppress unused-variable warning — unsubscribeRotation is intended
161
+ // for future shutdown plumbing; tests can ignore it.
162
+ void unsubscribeRotation;
163
+
164
+ 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` };
168
+ }
169
+ const dek = rng(KEY_BYTES);
170
+ await dekStore.set(tenantId, dek);
171
+ return { dek, dekRef: `dek:${tenantId}:v1` };
172
+ }
173
+
174
+ 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);
179
+ currentKekRef = newKekRef;
180
+ }
181
+
182
+ return {
183
+ get kekRef(): string {
184
+ return currentKekRef;
185
+ },
186
+ async encryptPayload(payload: unknown, tenantId: string): Promise<EncryptedRecord> {
187
+ if (typeof tenantId !== "string" || tenantId.length === 0) {
188
+ throw new AuditEncryptionError("tenantId is required");
189
+ }
190
+ const { dek, dekRef } = await getOrCreateDek(tenantId);
191
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
192
+ const iv = rng(IV_BYTES);
193
+ 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
197
+ // round-trip).
198
+ const dekIv = rng(IV_BYTES);
199
+ const { ciphertext: wrappedDek, tag: wrappedTag } = encryptBytes(dek, currentKekKey, dekIv);
200
+ return {
201
+ tenantId,
202
+ kekRef: currentKekRef,
203
+ dekRef,
204
+ iv: iv.toString("hex"),
205
+ tag: tag.toString("hex"),
206
+ encryptedPayload: ciphertext.toString("hex"),
207
+ wrappedDek: wrappedDek.toString("hex"),
208
+ wrappedDekIv: dekIv.toString("hex"),
209
+ wrappedDekTag: wrappedTag.toString("hex"),
210
+ };
211
+ },
212
+ async decryptPayload(record: EncryptedRecord): Promise<unknown> {
213
+ // Unwrap the DEK first.
214
+ const dek = decryptBytes(
215
+ Buffer.from(record.wrappedDek, "hex"),
216
+ currentKekKey,
217
+ Buffer.from(record.wrappedDekIv, "hex"),
218
+ Buffer.from(record.wrappedDekTag, "hex"),
219
+ );
220
+ const plaintext = decryptBytes(
221
+ Buffer.from(record.encryptedPayload, "hex"),
222
+ dek,
223
+ Buffer.from(record.iv, "hex"),
224
+ Buffer.from(record.tag, "hex"),
225
+ );
226
+ try {
227
+ return JSON.parse(plaintext.toString("utf8"));
228
+ } catch (err) {
229
+ throw new AuditEncryptionError("decrypted payload is not valid JSON", err);
230
+ }
231
+ },
232
+ async rotateKek(newKekValue: string, newKekRef: string): Promise<void> {
233
+ await rotateInternal(newKekValue, newKekRef);
234
+ },
235
+ };
236
+ }
237
+
238
+ export {
239
+ encryptBytes as _encryptBytesForTest,
240
+ decryptBytes as _decryptBytesForTest,
241
+ deriveKekKey as _deriveKekKeyForTest,
242
+ };