@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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
import type { Secrets } from "@crewhaus/secrets-manager";
|
|
3
|
+
/**
|
|
4
|
+
* Catalog R17 `audit-encryption` — Section 39 envelope encryption for
|
|
5
|
+
* audit-log payloads.
|
|
6
|
+
*
|
|
7
|
+
* Encrypts per-record JSON payloads with a tenant-scoped Data
|
|
8
|
+
* Encryption Key (DEK); the DEK itself is encrypted ("wrapped") with
|
|
9
|
+
* a Key Encryption Key (KEK) sourced from §27 `secrets-manager`. The
|
|
10
|
+
* resulting record carries
|
|
11
|
+
* { tenantId, kekRef, dekRef, kekSalt, iv, tag, encryptedPayload, ... }
|
|
12
|
+
* and is verifiable + decryptable by any caller with the same KEK.
|
|
13
|
+
*
|
|
14
|
+
* Algorithms:
|
|
15
|
+
* - AES-256-GCM for both DEK→payload and KEK→DEK wrapping. GCM is
|
|
16
|
+
* authenticated, so tampering with `encryptedPayload`, `iv`, or
|
|
17
|
+
* `tag` causes `decrypt` to throw — satisfies the §39 T8
|
|
18
|
+
* ciphertext-integrity requirement.
|
|
19
|
+
* - The 32-byte AES wrapping key is derived from the KEK string via
|
|
20
|
+
* scrypt (a salted, memory-hard KDF) with a per-record random salt.
|
|
21
|
+
* This holds even when the KEK is a low-entropy passphrase: scrypt
|
|
22
|
+
* stretches it and the persisted salt defeats precomputation
|
|
23
|
+
* (CWE-916 — a bare unsalted hash would not). The salt is stored on
|
|
24
|
+
* the record (`kekSalt`) so the same key can be re-derived at
|
|
25
|
+
* unwrap time.
|
|
26
|
+
* - 12-byte (96-bit) IVs randomly generated per record.
|
|
27
|
+
*
|
|
28
|
+
* Key rotation:
|
|
29
|
+
* `secrets.onRotation(...)` triggers `rotateKek()` which mints a fresh
|
|
30
|
+
* DEK version (`dek:<tenant>:vN+1`) for every tenant in the DEK store
|
|
31
|
+
* and adopts the new KEK as current. The prior KEK *value* is retained
|
|
32
|
+
* in-process keyed by its `kekRef`, so `decryptPayload` can re-derive
|
|
33
|
+
* the wrapping key for historical records (which keep their original
|
|
34
|
+
* `kekRef` + `kekSalt`) and still unwrap them (CWE-323 — without
|
|
35
|
+
* retaining prior material, rotation would strand old records).
|
|
36
|
+
* DEKs also roll automatically once a single version has wrapped more
|
|
37
|
+
* than `maxRecordsPerDek` records.
|
|
38
|
+
*
|
|
39
|
+
* Layer R17. Pairs with `audit-log` (R-infra — wraps `append` /
|
|
40
|
+
* `read`) and `secrets-manager` (§27 — KEK source).
|
|
41
|
+
*/
|
|
42
|
+
export declare class AuditEncryptionError extends CrewhausError {
|
|
43
|
+
readonly name = "AuditEncryptionError";
|
|
44
|
+
constructor(message: string, cause?: unknown);
|
|
45
|
+
}
|
|
46
|
+
export type EncryptedRecord = {
|
|
47
|
+
/** Tenant whose DEK was used. */
|
|
48
|
+
readonly tenantId: string;
|
|
49
|
+
/** Stable identifier for the KEK version used to wrap the DEK. */
|
|
50
|
+
readonly kekRef: string;
|
|
51
|
+
/** Stable identifier for the DEK used to encrypt the payload. */
|
|
52
|
+
readonly dekRef: string;
|
|
53
|
+
/**
|
|
54
|
+
* Per-record salt (hex) fed to the scrypt KEK-key derivation. Absent on
|
|
55
|
+
* legacy records written before the KDF migration; those fall back to
|
|
56
|
+
* the legacy unsalted-SHA-256 derivation for back-compat.
|
|
57
|
+
*/
|
|
58
|
+
readonly kekSalt?: string;
|
|
59
|
+
/** 96-bit GCM IV (24 hex chars). */
|
|
60
|
+
readonly iv: string;
|
|
61
|
+
/** 128-bit GCM auth tag (32 hex chars). */
|
|
62
|
+
readonly tag: string;
|
|
63
|
+
/** Encrypted payload (hex). */
|
|
64
|
+
readonly encryptedPayload: string;
|
|
65
|
+
/** Wrapped DEK (hex), sealed with `kekRef`. */
|
|
66
|
+
readonly wrappedDek: string;
|
|
67
|
+
/** Wrapped DEK IV (hex). */
|
|
68
|
+
readonly wrappedDekIv: string;
|
|
69
|
+
/** Wrapped DEK auth tag (hex). */
|
|
70
|
+
readonly wrappedDekTag: string;
|
|
71
|
+
};
|
|
72
|
+
export type AuditEncryptionOptions = {
|
|
73
|
+
readonly secrets: Secrets;
|
|
74
|
+
/** Name of the KEK in §27 secrets-manager. */
|
|
75
|
+
readonly kekName: string;
|
|
76
|
+
/**
|
|
77
|
+
* Stable identifier for the *boot* KEK value. Defaults to
|
|
78
|
+
* `kek:<kekName>:v1`. This is the `kekRef` stamped on records sealed
|
|
79
|
+
* before the first in-process rotation, and the key under which the
|
|
80
|
+
* boot KEK is held in the retain-for-decrypt registry. After one or
|
|
81
|
+
* more rotations, a restarted process boots with the *latest* KEK
|
|
82
|
+
* value; pass that rotation's ref here so historical refs stay stable
|
|
83
|
+
* and the boot value is not mistaken for the original `:v1` material.
|
|
84
|
+
*/
|
|
85
|
+
readonly kekRef?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Optional persistent DEK store. If omitted, DEKs live in-memory
|
|
88
|
+
* (process-local). Production should plug a tenant-scoped key store
|
|
89
|
+
* here (HSM, KMS, vault path) — see {@link createFileDekStore} for a
|
|
90
|
+
* file-backed implementation that survives restart.
|
|
91
|
+
*/
|
|
92
|
+
readonly dekStore?: DekStore;
|
|
93
|
+
/**
|
|
94
|
+
* Prior KEK material to re-seed at boot, keyed by the `kekRef` it was
|
|
95
|
+
* minted under. The in-process KEK registry that {@link rotateKek}
|
|
96
|
+
* populates does not survive a restart, so a freshly-constructed engine
|
|
97
|
+
* can only unwrap records sealed under the *boot* KEK. Operators that
|
|
98
|
+
* have rotated must re-provide each superseded KEK here so historical
|
|
99
|
+
* records (which embed their original `kekRef`) keep decrypting after a
|
|
100
|
+
* restart (CWE-323). Values are never persisted by this package; the
|
|
101
|
+
* operator re-supplies them from the secret backend's history.
|
|
102
|
+
*/
|
|
103
|
+
readonly retainedKeks?: ReadonlyArray<{
|
|
104
|
+
readonly kekRef: string;
|
|
105
|
+
readonly kekValue: string;
|
|
106
|
+
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Roll a tenant's DEK to a fresh version once it has wrapped this many
|
|
109
|
+
* records. Bounds the blast radius of any single DEK. Defaults to
|
|
110
|
+
* {@link DEFAULT_MAX_RECORDS_PER_DEK}.
|
|
111
|
+
*/
|
|
112
|
+
readonly maxRecordsPerDek?: number;
|
|
113
|
+
/** Test seam: deterministic IV/salt generator. */
|
|
114
|
+
readonly randomBytesImpl?: (n: number) => Buffer;
|
|
115
|
+
/** Test seam: synthetic Date.now. */
|
|
116
|
+
readonly now?: () => number;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Versioned DEK entry. `version` is the integer N behind the
|
|
120
|
+
* `dek:<tenant>:vN` ref; `uses` counts records encrypted under it so we
|
|
121
|
+
* can roll on the {@link AuditEncryptionOptions.maxRecordsPerDek}
|
|
122
|
+
* threshold.
|
|
123
|
+
*/
|
|
124
|
+
export type DekEntry = {
|
|
125
|
+
readonly dek: Buffer;
|
|
126
|
+
readonly version: number;
|
|
127
|
+
readonly uses: number;
|
|
128
|
+
};
|
|
129
|
+
export interface DekStore {
|
|
130
|
+
get(tenantId: string): Promise<Buffer | undefined>;
|
|
131
|
+
set(tenantId: string, dek: Buffer): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Optional versioned read. When present it is preferred over `get`, and
|
|
134
|
+
* carries the version + usage counter needed for rotation. Stores that
|
|
135
|
+
* implement only `get`/`set` are treated as version 1 with no usage
|
|
136
|
+
* tracking (rotation still re-mints; the threshold is a no-op).
|
|
137
|
+
*/
|
|
138
|
+
getEntry?(tenantId: string): Promise<DekEntry | undefined>;
|
|
139
|
+
/** Optional versioned write. Required for DEK versioning to take effect. */
|
|
140
|
+
setEntry?(tenantId: string, entry: DekEntry): Promise<void>;
|
|
141
|
+
/** Optional iteration over tenants holding a DEK. Required by `rotateKek`. */
|
|
142
|
+
tenants?(): Promise<ReadonlyArray<string>>;
|
|
143
|
+
}
|
|
144
|
+
export declare class InMemoryDekStore implements DekStore {
|
|
145
|
+
private readonly map;
|
|
146
|
+
constructor();
|
|
147
|
+
get(tenantId: string): Promise<Buffer | undefined>;
|
|
148
|
+
set(tenantId: string, dek: Buffer): Promise<void>;
|
|
149
|
+
getEntry(tenantId: string): Promise<DekEntry | undefined>;
|
|
150
|
+
setEntry(tenantId: string, entry: DekEntry): Promise<void>;
|
|
151
|
+
tenants(): Promise<ReadonlyArray<string>>;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Source of KEK material for {@link createFileDekStore}. The store wraps
|
|
155
|
+
* each DEK before it touches disk and unwraps on read, so it needs the
|
|
156
|
+
* *current* KEK to seal new writes and any *superseded* KEK (keyed by the
|
|
157
|
+
* `kekRef` recorded alongside the wrapped DEK) to open older files after
|
|
158
|
+
* a rotation. Operators construct this at boot from the same KEK(s) they
|
|
159
|
+
* re-provide to the engine — the store never persists the KEK value
|
|
160
|
+
* itself, only the wrapped DEK plus its `kekRef`.
|
|
161
|
+
*/
|
|
162
|
+
export interface KekProvider {
|
|
163
|
+
/** KEK used to wrap DEKs on write. */
|
|
164
|
+
current(): {
|
|
165
|
+
readonly kekRef: string;
|
|
166
|
+
readonly kekValue: string;
|
|
167
|
+
};
|
|
168
|
+
/** Resolve the KEK value a stored DEK was wrapped under, by `kekRef`. */
|
|
169
|
+
resolve(kekRef: string): string | undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Build a {@link KekProvider} from a current KEK plus zero or more
|
|
173
|
+
* superseded KEKs (keyed by their original `kekRef`). After a rotation,
|
|
174
|
+
* the operator re-supplies the prior KEK(s) here so the file store can
|
|
175
|
+
* unwrap DEK files sealed under them.
|
|
176
|
+
*/
|
|
177
|
+
export declare function staticKekProvider(current: {
|
|
178
|
+
readonly kekRef: string;
|
|
179
|
+
readonly kekValue: string;
|
|
180
|
+
}, retained?: ReadonlyArray<{
|
|
181
|
+
readonly kekRef: string;
|
|
182
|
+
readonly kekValue: string;
|
|
183
|
+
}>): KekProvider;
|
|
184
|
+
export type FileDekStoreOptions = {
|
|
185
|
+
/**
|
|
186
|
+
* Test seam: deterministic IV/salt generator for the wrapping step.
|
|
187
|
+
* Defaults to {@link randomBytes}.
|
|
188
|
+
*/
|
|
189
|
+
readonly randomBytesImpl?: (n: number) => Buffer;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* File-backed {@link DekStore} that persists DEKs so they (and their
|
|
193
|
+
* version + use-count) survive a restart. Each tenant's DEK lives in
|
|
194
|
+
* `<rootDir>/dek-<tenant>.json` written at mode `0o600`.
|
|
195
|
+
*
|
|
196
|
+
* SECURITY: the raw DEK is **never** written to disk. It is wrapped with
|
|
197
|
+
* the {@link KekProvider}'s current KEK (scrypt-derived AES-256-GCM key,
|
|
198
|
+
* the same scheme the engine uses for records) and only the *wrapped*
|
|
199
|
+
* bytes — together with the `kekRef` and salt needed to re-derive the
|
|
200
|
+
* unwrapping key — are persisted. The KEK *value* is supplied by the
|
|
201
|
+
* operator at boot and is never persisted (CWE-312/CWE-256): an attacker
|
|
202
|
+
* with read access to `rootDir` gets only ciphertext.
|
|
203
|
+
*
|
|
204
|
+
* After a rotation the operator must keep providing the prior KEK(s) via
|
|
205
|
+
* {@link staticKekProvider}'s `retained` list until every tenant's file
|
|
206
|
+
* has been rewritten under the new KEK (which happens on the next write
|
|
207
|
+
* for that tenant, including the re-mint that `rotateKek` performs).
|
|
208
|
+
*/
|
|
209
|
+
export declare function createFileDekStore(rootDir: string, kek: KekProvider, opts?: FileDekStoreOptions): DekStore;
|
|
210
|
+
/** Default DEK roll threshold. */
|
|
211
|
+
export declare const DEFAULT_MAX_RECORDS_PER_DEK = 100000;
|
|
212
|
+
/**
|
|
213
|
+
* Derive the 32-byte AES wrapping key from the KEK string using scrypt
|
|
214
|
+
* with the supplied salt. scrypt is salted + memory-hard, so this is
|
|
215
|
+
* sound even when `kekValue` is a low-entropy passphrase (CWE-916). The
|
|
216
|
+
* salt must be persisted (`EncryptedRecord.kekSalt`) to re-derive.
|
|
217
|
+
*/
|
|
218
|
+
declare function deriveKekKey(kekValue: string, salt: Buffer): Buffer;
|
|
219
|
+
/**
|
|
220
|
+
* Legacy unsalted-SHA-256 derivation. Retained only to unwrap records
|
|
221
|
+
* written before the scrypt migration (those carry no `kekSalt`). Never
|
|
222
|
+
* used for new records.
|
|
223
|
+
*/
|
|
224
|
+
declare function deriveKekKeyLegacy(kekValue: string): Buffer;
|
|
225
|
+
declare function encryptBytes(plaintext: Buffer, key: Buffer, iv: Buffer): {
|
|
226
|
+
ciphertext: Buffer;
|
|
227
|
+
tag: Buffer;
|
|
228
|
+
};
|
|
229
|
+
declare function decryptBytes(ciphertext: Buffer, key: Buffer, iv: Buffer, tag: Buffer): Buffer;
|
|
230
|
+
export interface AuditEncryption {
|
|
231
|
+
/** Encrypt the JSON-serializable payload for this tenant. */
|
|
232
|
+
encryptPayload(payload: unknown, tenantId: string): Promise<EncryptedRecord>;
|
|
233
|
+
/** Decrypt and parse a previously encrypted record. */
|
|
234
|
+
decryptPayload(record: EncryptedRecord): Promise<unknown>;
|
|
235
|
+
/**
|
|
236
|
+
* Adopt a new KEK and re-key every tenant's DEK to a fresh version.
|
|
237
|
+
* Production callers subscribe via `secrets.onRotation(handler)` and
|
|
238
|
+
* forward to this. The prior KEK value is retained in-process so
|
|
239
|
+
* historical records (which keep their original `kekRef`) still
|
|
240
|
+
* decrypt.
|
|
241
|
+
*/
|
|
242
|
+
rotateKek(newKekValue: string, newKekRef: string): Promise<void>;
|
|
243
|
+
/** Current KEK ref. */
|
|
244
|
+
readonly kekRef: string;
|
|
245
|
+
}
|
|
246
|
+
export declare function createAuditEncryption(opts: AuditEncryptionOptions): Promise<AuditEncryption>;
|
|
247
|
+
export { encryptBytes as _encryptBytesForTest, decryptBytes as _decryptBytesForTest, deriveKekKey as _deriveKekKeyForTest, deriveKekKeyLegacy as _deriveKekKeyLegacyForTest, };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
5
|
+
/**
|
|
6
|
+
* Catalog R17 `audit-encryption` — Section 39 envelope encryption for
|
|
7
|
+
* audit-log payloads.
|
|
8
|
+
*
|
|
9
|
+
* Encrypts per-record JSON payloads with a tenant-scoped Data
|
|
10
|
+
* Encryption Key (DEK); the DEK itself is encrypted ("wrapped") with
|
|
11
|
+
* a Key Encryption Key (KEK) sourced from §27 `secrets-manager`. The
|
|
12
|
+
* resulting record carries
|
|
13
|
+
* { tenantId, kekRef, dekRef, kekSalt, iv, tag, encryptedPayload, ... }
|
|
14
|
+
* and is verifiable + decryptable by any caller with the same KEK.
|
|
15
|
+
*
|
|
16
|
+
* Algorithms:
|
|
17
|
+
* - AES-256-GCM for both DEK→payload and KEK→DEK wrapping. GCM is
|
|
18
|
+
* authenticated, so tampering with `encryptedPayload`, `iv`, or
|
|
19
|
+
* `tag` causes `decrypt` to throw — satisfies the §39 T8
|
|
20
|
+
* ciphertext-integrity requirement.
|
|
21
|
+
* - The 32-byte AES wrapping key is derived from the KEK string via
|
|
22
|
+
* scrypt (a salted, memory-hard KDF) with a per-record random salt.
|
|
23
|
+
* This holds even when the KEK is a low-entropy passphrase: scrypt
|
|
24
|
+
* stretches it and the persisted salt defeats precomputation
|
|
25
|
+
* (CWE-916 — a bare unsalted hash would not). The salt is stored on
|
|
26
|
+
* the record (`kekSalt`) so the same key can be re-derived at
|
|
27
|
+
* unwrap time.
|
|
28
|
+
* - 12-byte (96-bit) IVs randomly generated per record.
|
|
29
|
+
*
|
|
30
|
+
* Key rotation:
|
|
31
|
+
* `secrets.onRotation(...)` triggers `rotateKek()` which mints a fresh
|
|
32
|
+
* DEK version (`dek:<tenant>:vN+1`) for every tenant in the DEK store
|
|
33
|
+
* and adopts the new KEK as current. The prior KEK *value* is retained
|
|
34
|
+
* in-process keyed by its `kekRef`, so `decryptPayload` can re-derive
|
|
35
|
+
* the wrapping key for historical records (which keep their original
|
|
36
|
+
* `kekRef` + `kekSalt`) and still unwrap them (CWE-323 — without
|
|
37
|
+
* retaining prior material, rotation would strand old records).
|
|
38
|
+
* DEKs also roll automatically once a single version has wrapped more
|
|
39
|
+
* than `maxRecordsPerDek` records.
|
|
40
|
+
*
|
|
41
|
+
* Layer R17. Pairs with `audit-log` (R-infra — wraps `append` /
|
|
42
|
+
* `read`) and `secrets-manager` (§27 — KEK source).
|
|
43
|
+
*/
|
|
44
|
+
export class AuditEncryptionError extends CrewhausError {
|
|
45
|
+
name = "AuditEncryptionError";
|
|
46
|
+
constructor(message, cause) {
|
|
47
|
+
super("config", message, cause);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class InMemoryDekStore {
|
|
51
|
+
map;
|
|
52
|
+
constructor() {
|
|
53
|
+
this.map = new Map();
|
|
54
|
+
}
|
|
55
|
+
async get(tenantId) {
|
|
56
|
+
return this.map.get(tenantId)?.dek;
|
|
57
|
+
}
|
|
58
|
+
async set(tenantId, dek) {
|
|
59
|
+
const prev = this.map.get(tenantId);
|
|
60
|
+
this.map.set(tenantId, {
|
|
61
|
+
dek: Buffer.from(dek),
|
|
62
|
+
version: prev?.version ?? 1,
|
|
63
|
+
uses: 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async getEntry(tenantId) {
|
|
67
|
+
const entry = this.map.get(tenantId);
|
|
68
|
+
return entry === undefined ? undefined : { ...entry, dek: Buffer.from(entry.dek) };
|
|
69
|
+
}
|
|
70
|
+
async setEntry(tenantId, entry) {
|
|
71
|
+
this.map.set(tenantId, { ...entry, dek: Buffer.from(entry.dek) });
|
|
72
|
+
}
|
|
73
|
+
async tenants() {
|
|
74
|
+
return [...this.map.keys()];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build a {@link KekProvider} from a current KEK plus zero or more
|
|
79
|
+
* superseded KEKs (keyed by their original `kekRef`). After a rotation,
|
|
80
|
+
* the operator re-supplies the prior KEK(s) here so the file store can
|
|
81
|
+
* unwrap DEK files sealed under them.
|
|
82
|
+
*/
|
|
83
|
+
export function staticKekProvider(current, retained = []) {
|
|
84
|
+
const byRef = new Map();
|
|
85
|
+
for (const { kekRef, kekValue } of retained)
|
|
86
|
+
byRef.set(kekRef, kekValue);
|
|
87
|
+
// The current KEK takes precedence over any same-ref retained entry.
|
|
88
|
+
byRef.set(current.kekRef, current.kekValue);
|
|
89
|
+
return {
|
|
90
|
+
current: () => ({ kekRef: current.kekRef, kekValue: current.kekValue }),
|
|
91
|
+
resolve: (kekRef) => byRef.get(kekRef),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* File-backed {@link DekStore} that persists DEKs so they (and their
|
|
96
|
+
* version + use-count) survive a restart. Each tenant's DEK lives in
|
|
97
|
+
* `<rootDir>/dek-<tenant>.json` written at mode `0o600`.
|
|
98
|
+
*
|
|
99
|
+
* SECURITY: the raw DEK is **never** written to disk. It is wrapped with
|
|
100
|
+
* the {@link KekProvider}'s current KEK (scrypt-derived AES-256-GCM key,
|
|
101
|
+
* the same scheme the engine uses for records) and only the *wrapped*
|
|
102
|
+
* bytes — together with the `kekRef` and salt needed to re-derive the
|
|
103
|
+
* unwrapping key — are persisted. The KEK *value* is supplied by the
|
|
104
|
+
* operator at boot and is never persisted (CWE-312/CWE-256): an attacker
|
|
105
|
+
* with read access to `rootDir` gets only ciphertext.
|
|
106
|
+
*
|
|
107
|
+
* After a rotation the operator must keep providing the prior KEK(s) via
|
|
108
|
+
* {@link staticKekProvider}'s `retained` list until every tenant's file
|
|
109
|
+
* has been rewritten under the new KEK (which happens on the next write
|
|
110
|
+
* for that tenant, including the re-mint that `rotateKek` performs).
|
|
111
|
+
*/
|
|
112
|
+
export function createFileDekStore(rootDir, kek, opts = {}) {
|
|
113
|
+
if (typeof rootDir !== "string" || rootDir.length === 0) {
|
|
114
|
+
throw new AuditEncryptionError("createFileDekStore: rootDir is required");
|
|
115
|
+
}
|
|
116
|
+
const rng = opts.randomBytesImpl ?? randomBytes;
|
|
117
|
+
mkdirSync(rootDir, { recursive: true, mode: 0o700 });
|
|
118
|
+
const FILE_PREFIX = "dek-";
|
|
119
|
+
const FILE_SUFFIX = ".json";
|
|
120
|
+
function pathFor(tenantId) {
|
|
121
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(tenantId)) {
|
|
122
|
+
throw new AuditEncryptionError(`createFileDekStore: invalid tenantId "${tenantId}" (must match [A-Za-z0-9_.-]+)`);
|
|
123
|
+
}
|
|
124
|
+
return join(rootDir, `${FILE_PREFIX}${tenantId}${FILE_SUFFIX}`);
|
|
125
|
+
}
|
|
126
|
+
/** Wrap a raw DEK under the current KEK for persistence. */
|
|
127
|
+
function wrap(dek) {
|
|
128
|
+
const { kekRef, kekValue } = kek.current();
|
|
129
|
+
const salt = rng(SALT_BYTES);
|
|
130
|
+
const kekKey = deriveKekKey(kekValue, salt);
|
|
131
|
+
const iv = rng(IV_BYTES);
|
|
132
|
+
const { ciphertext, tag } = encryptBytes(dek, kekKey, iv);
|
|
133
|
+
return {
|
|
134
|
+
kekRef,
|
|
135
|
+
kekSalt: salt.toString("hex"),
|
|
136
|
+
wrappedDek: ciphertext.toString("hex"),
|
|
137
|
+
wrappedDekIv: iv.toString("hex"),
|
|
138
|
+
wrappedDekTag: tag.toString("hex"),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Unwrap a persisted DEK using the KEK its `kekRef` selects. */
|
|
142
|
+
function unwrap(p) {
|
|
143
|
+
const kekValue = kek.resolve(p.kekRef);
|
|
144
|
+
if (kekValue === undefined) {
|
|
145
|
+
throw new AuditEncryptionError(`createFileDekStore: no KEK material for kekRef ${p.kekRef}; cannot unwrap persisted DEK (re-provide the prior KEK via staticKekProvider's retained list)`);
|
|
146
|
+
}
|
|
147
|
+
const kekKey = deriveKekKey(kekValue, Buffer.from(p.kekSalt, "hex"));
|
|
148
|
+
return decryptBytes(Buffer.from(p.wrappedDek, "hex"), kekKey, Buffer.from(p.wrappedDekIv, "hex"), Buffer.from(p.wrappedDekTag, "hex"));
|
|
149
|
+
}
|
|
150
|
+
function readPersisted(tenantId) {
|
|
151
|
+
const p = pathFor(tenantId);
|
|
152
|
+
if (!existsSync(p))
|
|
153
|
+
return undefined;
|
|
154
|
+
const raw = readFileSync(p, "utf8");
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(raw);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
throw new AuditEncryptionError(`createFileDekStore: corrupt DEK file at ${p}`, err);
|
|
161
|
+
}
|
|
162
|
+
return parsed;
|
|
163
|
+
}
|
|
164
|
+
/** Atomic write at 0o600: write `.tmp`, then rename into place. */
|
|
165
|
+
function writePersisted(tenantId, value) {
|
|
166
|
+
const p = pathFor(tenantId);
|
|
167
|
+
const tmp = `${p}.tmp`;
|
|
168
|
+
writeFileSync(tmp, JSON.stringify(value), { encoding: "utf8", mode: 0o600 });
|
|
169
|
+
renameSync(tmp, p);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
async get(tenantId) {
|
|
173
|
+
const p = readPersisted(tenantId);
|
|
174
|
+
return p === undefined ? undefined : unwrap(p);
|
|
175
|
+
},
|
|
176
|
+
async set(tenantId, dek) {
|
|
177
|
+
const prev = readPersisted(tenantId);
|
|
178
|
+
writePersisted(tenantId, { ...wrap(dek), version: prev?.version ?? 1, uses: 0 });
|
|
179
|
+
},
|
|
180
|
+
async getEntry(tenantId) {
|
|
181
|
+
const p = readPersisted(tenantId);
|
|
182
|
+
if (p === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
return { dek: unwrap(p), version: p.version, uses: p.uses };
|
|
185
|
+
},
|
|
186
|
+
async setEntry(tenantId, entry) {
|
|
187
|
+
writePersisted(tenantId, {
|
|
188
|
+
...wrap(entry.dek),
|
|
189
|
+
version: entry.version,
|
|
190
|
+
uses: entry.uses,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
async tenants() {
|
|
194
|
+
if (!existsSync(rootDir))
|
|
195
|
+
return [];
|
|
196
|
+
return readdirSync(rootDir)
|
|
197
|
+
.filter((f) => f.startsWith(FILE_PREFIX) && f.endsWith(FILE_SUFFIX))
|
|
198
|
+
.map((f) => f.slice(FILE_PREFIX.length, f.length - FILE_SUFFIX.length));
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const KEY_BYTES = 32; // AES-256
|
|
203
|
+
const IV_BYTES = 12; // GCM standard
|
|
204
|
+
const SALT_BYTES = 16; // scrypt salt
|
|
205
|
+
/** scrypt cost params: N=2^15 keeps derivation well under a frame budget. */
|
|
206
|
+
const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
207
|
+
/** Default DEK roll threshold. */
|
|
208
|
+
export const DEFAULT_MAX_RECORDS_PER_DEK = 100_000;
|
|
209
|
+
/**
|
|
210
|
+
* Derive the 32-byte AES wrapping key from the KEK string using scrypt
|
|
211
|
+
* with the supplied salt. scrypt is salted + memory-hard, so this is
|
|
212
|
+
* sound even when `kekValue` is a low-entropy passphrase (CWE-916). The
|
|
213
|
+
* salt must be persisted (`EncryptedRecord.kekSalt`) to re-derive.
|
|
214
|
+
*/
|
|
215
|
+
function deriveKekKey(kekValue, salt) {
|
|
216
|
+
return scryptSync(kekValue, salt, KEY_BYTES, SCRYPT_PARAMS);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Legacy unsalted-SHA-256 derivation. Retained only to unwrap records
|
|
220
|
+
* written before the scrypt migration (those carry no `kekSalt`). Never
|
|
221
|
+
* used for new records.
|
|
222
|
+
*/
|
|
223
|
+
function deriveKekKeyLegacy(kekValue) {
|
|
224
|
+
return createHash("sha256").update(kekValue).digest();
|
|
225
|
+
}
|
|
226
|
+
function encryptBytes(plaintext, key, iv) {
|
|
227
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
228
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
229
|
+
const tag = cipher.getAuthTag();
|
|
230
|
+
return { ciphertext, tag };
|
|
231
|
+
}
|
|
232
|
+
function decryptBytes(ciphertext, key, iv, tag) {
|
|
233
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
234
|
+
decipher.setAuthTag(tag);
|
|
235
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
236
|
+
}
|
|
237
|
+
export async function createAuditEncryption(opts) {
|
|
238
|
+
if (typeof opts.kekName !== "string" || opts.kekName.length === 0) {
|
|
239
|
+
throw new AuditEncryptionError("kekName is required");
|
|
240
|
+
}
|
|
241
|
+
if (opts.secrets === undefined) {
|
|
242
|
+
throw new AuditEncryptionError("secrets is required");
|
|
243
|
+
}
|
|
244
|
+
const dekStore = opts.dekStore ?? new InMemoryDekStore();
|
|
245
|
+
const rng = opts.randomBytesImpl ?? randomBytes;
|
|
246
|
+
const maxRecordsPerDek = opts.maxRecordsPerDek !== undefined && opts.maxRecordsPerDek > 0
|
|
247
|
+
? opts.maxRecordsPerDek
|
|
248
|
+
: DEFAULT_MAX_RECORDS_PER_DEK;
|
|
249
|
+
const initialKekValue = await opts.secrets.get(opts.kekName);
|
|
250
|
+
let currentKekRef = typeof opts.kekRef === "string" && opts.kekRef.length > 0
|
|
251
|
+
? opts.kekRef
|
|
252
|
+
: `kek:${opts.kekName}:v1`;
|
|
253
|
+
let currentKekValue = initialKekValue;
|
|
254
|
+
// Retain every KEK value we have ever held, keyed by its ref, so
|
|
255
|
+
// `decryptPayload` can re-derive the wrapping key for records sealed
|
|
256
|
+
// under a now-superseded KEK (CWE-323). Production deployments that
|
|
257
|
+
// restart rehydrate the superseded entries from `retainedKeks` (the
|
|
258
|
+
// secret backend's history), since the registry is otherwise
|
|
259
|
+
// process-local and lost across restarts.
|
|
260
|
+
const kekValuesByRef = new Map([[currentKekRef, currentKekValue]]);
|
|
261
|
+
for (const { kekRef, kekValue } of opts.retainedKeks ?? []) {
|
|
262
|
+
// The boot KEK already occupies `currentKekRef`; don't let a stale
|
|
263
|
+
// retained entry shadow it.
|
|
264
|
+
if (!kekValuesByRef.has(kekRef)) {
|
|
265
|
+
kekValuesByRef.set(kekRef, kekValue);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Auto-subscribe to rotation events. The re-key runs fire-and-forget, but
|
|
269
|
+
// its rejection is contained locally: a failed event-driven rotation must
|
|
270
|
+
// never escape as an unhandled rejection (which could crash the host
|
|
271
|
+
// process). The engine simply keeps its last-good KEK state and historical
|
|
272
|
+
// records still decrypt.
|
|
273
|
+
const unsubscribeRotation = opts.secrets.onRotation((event) => {
|
|
274
|
+
if (event.name !== opts.kekName)
|
|
275
|
+
return;
|
|
276
|
+
void rotateInternal(event.newValue, `kek:${opts.kekName}:${event.rotatedAt}`).catch(() => {
|
|
277
|
+
/* contained — see comment above */
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
// Suppress unused-variable warning — unsubscribeRotation is intended
|
|
281
|
+
// for future shutdown plumbing; tests can ignore it.
|
|
282
|
+
void unsubscribeRotation;
|
|
283
|
+
async function readEntry(tenantId) {
|
|
284
|
+
if (dekStore.getEntry !== undefined) {
|
|
285
|
+
return dekStore.getEntry(tenantId);
|
|
286
|
+
}
|
|
287
|
+
const dek = await dekStore.get(tenantId);
|
|
288
|
+
if (dek === undefined || dek.length !== KEY_BYTES)
|
|
289
|
+
return undefined;
|
|
290
|
+
return { dek, version: 1, uses: 0 };
|
|
291
|
+
}
|
|
292
|
+
async function writeEntry(tenantId, entry) {
|
|
293
|
+
if (dekStore.setEntry !== undefined) {
|
|
294
|
+
await dekStore.setEntry(tenantId, entry);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await dekStore.set(tenantId, entry.dek);
|
|
298
|
+
}
|
|
299
|
+
function mintDek(tenantId, version) {
|
|
300
|
+
return { dek: rng(KEY_BYTES), version, uses: 0 };
|
|
301
|
+
}
|
|
302
|
+
async function getOrCreateDek(tenantId) {
|
|
303
|
+
let entry = await readEntry(tenantId);
|
|
304
|
+
if (entry === undefined) {
|
|
305
|
+
entry = mintDek(tenantId, 1);
|
|
306
|
+
}
|
|
307
|
+
else if (entry.uses >= maxRecordsPerDek) {
|
|
308
|
+
// Roll to a fresh DEK version once the current one is exhausted.
|
|
309
|
+
entry = mintDek(tenantId, entry.version + 1);
|
|
310
|
+
}
|
|
311
|
+
const next = { dek: entry.dek, version: entry.version, uses: entry.uses + 1 };
|
|
312
|
+
await writeEntry(tenantId, next);
|
|
313
|
+
return { dek: next.dek, dekRef: `dek:${tenantId}:v${next.version}` };
|
|
314
|
+
}
|
|
315
|
+
async function rotateInternal(newKekValue, newKekRef) {
|
|
316
|
+
// Retain the prior KEK value so historical records keep decrypting,
|
|
317
|
+
// then adopt the new one as current.
|
|
318
|
+
kekValuesByRef.set(newKekRef, newKekValue);
|
|
319
|
+
currentKekValue = newKekValue;
|
|
320
|
+
currentKekRef = newKekRef;
|
|
321
|
+
// Re-key every tenant's DEK to a fresh version. Records already on
|
|
322
|
+
// disk keep their old `dekRef`/`kekRef`; subsequent writes use the
|
|
323
|
+
// new DEK version wrapped under the new KEK.
|
|
324
|
+
if (dekStore.tenants !== undefined) {
|
|
325
|
+
const tenants = await dekStore.tenants();
|
|
326
|
+
for (const tenantId of tenants) {
|
|
327
|
+
const entry = await readEntry(tenantId);
|
|
328
|
+
if (entry === undefined)
|
|
329
|
+
continue;
|
|
330
|
+
await writeEntry(tenantId, mintDek(tenantId, entry.version + 1));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function deriveForRef(kekRef, kekSalt) {
|
|
335
|
+
const kekValue = kekValuesByRef.get(kekRef);
|
|
336
|
+
if (kekValue === undefined) {
|
|
337
|
+
throw new AuditEncryptionError(`no KEK material retained for kekRef ${kekRef}; cannot unwrap DEK`);
|
|
338
|
+
}
|
|
339
|
+
// Legacy records (pre-KDF migration) carry no salt — fall back to the
|
|
340
|
+
// unsalted derivation that originally sealed them.
|
|
341
|
+
if (kekSalt === undefined) {
|
|
342
|
+
return deriveKekKeyLegacy(kekValue);
|
|
343
|
+
}
|
|
344
|
+
return deriveKekKey(kekValue, Buffer.from(kekSalt, "hex"));
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
get kekRef() {
|
|
348
|
+
return currentKekRef;
|
|
349
|
+
},
|
|
350
|
+
async encryptPayload(payload, tenantId) {
|
|
351
|
+
if (typeof tenantId !== "string" || tenantId.length === 0) {
|
|
352
|
+
throw new AuditEncryptionError("tenantId is required");
|
|
353
|
+
}
|
|
354
|
+
const { dek, dekRef } = await getOrCreateDek(tenantId);
|
|
355
|
+
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
356
|
+
const iv = rng(IV_BYTES);
|
|
357
|
+
const { ciphertext, tag } = encryptBytes(plaintext, dek, iv);
|
|
358
|
+
// Derive the wrapping key with a fresh per-record salt, then wrap
|
|
359
|
+
// the DEK with the current KEK so we can persist the wrapped form
|
|
360
|
+
// alongside the record (production callers may store the wrapped
|
|
361
|
+
// DEK out-of-band; we include it here for self-contained
|
|
362
|
+
// round-trip).
|
|
363
|
+
const salt = rng(SALT_BYTES);
|
|
364
|
+
const kekKey = deriveKekKey(currentKekValue, salt);
|
|
365
|
+
const dekIv = rng(IV_BYTES);
|
|
366
|
+
const { ciphertext: wrappedDek, tag: wrappedTag } = encryptBytes(dek, kekKey, dekIv);
|
|
367
|
+
return {
|
|
368
|
+
tenantId,
|
|
369
|
+
kekRef: currentKekRef,
|
|
370
|
+
dekRef,
|
|
371
|
+
kekSalt: salt.toString("hex"),
|
|
372
|
+
iv: iv.toString("hex"),
|
|
373
|
+
tag: tag.toString("hex"),
|
|
374
|
+
encryptedPayload: ciphertext.toString("hex"),
|
|
375
|
+
wrappedDek: wrappedDek.toString("hex"),
|
|
376
|
+
wrappedDekIv: dekIv.toString("hex"),
|
|
377
|
+
wrappedDekTag: wrappedTag.toString("hex"),
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
async decryptPayload(record) {
|
|
381
|
+
// Select the unwrapping KEK by the record's own `kekRef` so records
|
|
382
|
+
// sealed under a superseded KEK still decrypt after rotation.
|
|
383
|
+
const kekKey = deriveForRef(record.kekRef, record.kekSalt);
|
|
384
|
+
const dek = decryptBytes(Buffer.from(record.wrappedDek, "hex"), kekKey, Buffer.from(record.wrappedDekIv, "hex"), Buffer.from(record.wrappedDekTag, "hex"));
|
|
385
|
+
const plaintext = decryptBytes(Buffer.from(record.encryptedPayload, "hex"), dek, Buffer.from(record.iv, "hex"), Buffer.from(record.tag, "hex"));
|
|
386
|
+
try {
|
|
387
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
throw new AuditEncryptionError("decrypted payload is not valid JSON", err);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
async rotateKek(newKekValue, newKekRef) {
|
|
394
|
+
await rotateInternal(newKekValue, newKekRef);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
export { encryptBytes as _encryptBytesForTest, decryptBytes as _decryptBytesForTest, deriveKekKey as _deriveKekKeyForTest, deriveKekKeyLegacy as _deriveKekKeyLegacyForTest, };
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/audit-encryption",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/audit-log": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/secrets-manager": "0.1.
|
|
18
|
+
"@crewhaus/audit-log": "0.1.6",
|
|
19
|
+
"@crewhaus/errors": "0.1.6",
|
|
20
|
+
"@crewhaus/secrets-manager": "0.1.6"
|
|
18
21
|
},
|
|
19
22
|
"license": "Apache-2.0",
|
|
20
23
|
"author": {
|
|
@@ -34,5 +37,5 @@
|
|
|
34
37
|
"publishConfig": {
|
|
35
38
|
"access": "public"
|
|
36
39
|
},
|
|
37
|
-
"files": ["
|
|
40
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
38
41
|
}
|