Skip to content

Commit bc22fed

Browse files
authored
feat(core): add salted one-use GCM key wrapping (#18)
1 parent 09903d3 commit bc22fed

7 files changed

Lines changed: 557 additions & 36 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@nestm/crypto": minor
3+
---
4+
5+
Derive a one-use AES-256-GCM wrapping key with HKDF-SHA256 and a fresh 256-bit salt for every new
6+
local key-ring data key while retaining read-only compatibility with legacy A256GCMKW envelopes.
7+
Version 2 removes the need for a durable global wrapper-invocation counter and keeps 128-bit
8+
authentication.

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ The `nmc1` format constrains registered ciphers to a 12-byte nonce so batch encr
304304
final four bytes for a collision-free operation counter. The cipher/provider contracts are extensible,
305305
but an algorithm needing a different nonce construction requires a future envelope version.
306306

307+
`AesKeyRingProvider` derives a one-use AES-256-GCM wrapping key from the long-lived key, a fresh
308+
256-bit random salt, and domain-separated HKDF-SHA256 info bound to the key reference and wrapping
309+
context. The one-use key makes the format's fixed 96-bit GCM nonce safe without a durable invocation
310+
counter. New wrappers are 81 bytes (`version || salt || ciphertext || tag`), tagged version 2, and
311+
report `NESTM-A256GCM-HKDF-SHA256-SALT256-V2`; the 61-byte version 1 `A256GCMKW` wrappers written
312+
previously stay readable, so no stored key needs rewrapping.
313+
307314
Wrapping-key rotation does not require immediately rewriting every value: keep old keys in the local
308315
ring as decrypt-only entries, or keep an old named provider in `allowedProviders`. Use `reencrypt()` to
309316
move one authenticated value to the current route, then retire old material only after verifying no

SECURITY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ backup/restore policy, and deciding which data must be encrypted.
4242
versioned as `nmc1.<protected>.<wrappedKey>.<iv>.<ciphertext>.<tag>`.
4343
- Wrapped data keys use a configured local AES key-encryption-key ring, RSA-OAEP-SHA256, AWS KMS,
4444
Google Cloud KMS, or Azure Key Vault/Managed HSM.
45+
- The local AES key ring derives a one-use AES-256-GCM wrapping key with HKDF-SHA256 from the
46+
long-lived key and a fresh 256-bit salt. Domain-separated derivation info binds the version, key
47+
reference, and a length-framed digest of the wrapping context. Each one-use key encrypts exactly
48+
one 32-byte data key with a fixed 96-bit IV and a 128-bit tag. Salt collision probability replaces
49+
the durable global counter previously required to prove direct GCM nonce uniqueness. Envelopes
50+
written before this construction remain readable under their `A256GCMKW` algorithm name and
51+
version byte.
4552
- Plaintext data keys are held as `KeyObject` values as early as practical. Temporary byte buffers are
4653
zeroed best-effort, but JavaScript runtimes cannot guarantee erasure of every copy.
4754
- No persistent plaintext data-key cache is part of the design.

src/core/aes-key-ring.provider.ts

Lines changed: 151 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
11
import {
22
createCipheriv,
33
createDecipheriv,
4+
createHash,
45
createSecretKey,
56
generateKeySync,
7+
hkdfSync,
68
randomBytes,
9+
type DecipherGCM,
710
type KeyObject,
811
} from "node:crypto";
912
import { authenticationFailed, CryptoError, throwIfAborted } from "./errors.js";
1013
import type { DataKeyContext, DataKeyProvider, GeneratedDataKey, WrappedDataKey } from "./types.js";
1114

15+
/** Salt-derived, one-use AES-256-GCM key wrapping used for new local key-ring writes. */
16+
export const AES_GCM_HKDF_SHA256_KEY_WRAP = "NESTM-A256GCM-HKDF-SHA256-SALT256-V2";
17+
/** @deprecated Read-only compatibility for envelopes written before AES_GCM_HKDF_SHA256_KEY_WRAP. */
1218
export const AES_GCM_KEY_WRAP = "A256GCMKW";
13-
const WRAP_VERSION = 1;
14-
const WRAPPED_LENGTH = 1 + 12 + 32 + 16;
19+
20+
const SALTED_WRAP_VERSION = 2;
21+
const SALT_BYTES = 32;
22+
const DATA_KEY_BYTES = 32;
23+
const TAG_BYTES = 16;
24+
const SALTED_WRAPPED_LENGTH = 1 + SALT_BYTES + DATA_KEY_BYTES + TAG_BYTES;
25+
const FIXED_WRAP_IV = new Uint8Array(12);
26+
const KEY_DERIVATION_INFO = "nestm:aes-key-ring:a256gcm-hkdf-sha256-salt256:v2\0";
27+
const KEY_REFERENCE_CONTEXT = "nestm:aes-key-ring:key-reference:v2\0";
28+
const WRAPPING_CONTEXT = "nestm:aes-key-ring:wrapping-context:v2\0";
29+
const WRAP_AUTHENTICATED_DATA = Buffer.concat([
30+
Buffer.from("nestm:aes-key-ring:wrapped-data-key:v2\0", "utf8"),
31+
Buffer.of(SALTED_WRAP_VERSION),
32+
]);
33+
const LEGACY_WRAP_VERSION = 1;
34+
const LEGACY_WRAPPED_LENGTH = 1 + 12 + 32 + 16;
1535

1636
export interface AesKeyRingProviderOptions {
1737
readonly activeKeyId: string;
@@ -42,6 +62,57 @@ function validateKeyId(keyId: string): void {
4262
}
4363
}
4464

65+
function framedDigest(domain: string, value: Uint8Array): Buffer {
66+
const length = Buffer.alloc(8);
67+
length.writeBigUInt64BE(BigInt(value.byteLength));
68+
try {
69+
return createHash("sha256").update(domain, "utf8").update(length).update(value).digest();
70+
} finally {
71+
length.fill(0);
72+
}
73+
}
74+
75+
function deriveOneUseWrappingKey(
76+
kek: KeyObject,
77+
salt: Uint8Array,
78+
keyReference: string,
79+
wrappingContext: Uint8Array,
80+
): Buffer {
81+
const reference = Buffer.from(keyReference, "utf8");
82+
const referenceDigest = framedDigest(KEY_REFERENCE_CONTEXT, reference);
83+
const contextDigest = framedDigest(WRAPPING_CONTEXT, wrappingContext);
84+
const info = Buffer.concat([
85+
Buffer.from(KEY_DERIVATION_INFO, "utf8"),
86+
Buffer.of(SALTED_WRAP_VERSION),
87+
referenceDigest,
88+
contextDigest,
89+
]);
90+
try {
91+
return Buffer.from(hkdfSync("sha256", kek, salt, info, DATA_KEY_BYTES));
92+
} finally {
93+
reference.fill(0);
94+
referenceDigest.fill(0);
95+
contextDigest.fill(0);
96+
info.fill(0);
97+
}
98+
}
99+
100+
function decryptDataKey(decipher: DecipherGCM, ciphertext: Uint8Array): KeyObject {
101+
const first = decipher.update(ciphertext);
102+
let last: Buffer | undefined;
103+
let raw: Buffer | undefined;
104+
try {
105+
last = decipher.final();
106+
raw = Buffer.concat([first, last]);
107+
if (raw.byteLength !== DATA_KEY_BYTES) throw authenticationFailed();
108+
return createSecretKey(raw);
109+
} finally {
110+
first.fill(0);
111+
last?.fill(0);
112+
raw?.fill(0);
113+
}
114+
}
115+
45116
export class AesKeyRingProvider implements DataKeyProvider {
46117
readonly #activeKeyId: string;
47118
readonly #keys: ReadonlyMap<string, KeyObject>;
@@ -63,69 +134,114 @@ export class AesKeyRingProvider implements DataKeyProvider {
63134
async generateDataKey(context: DataKeyContext): Promise<GeneratedDataKey> {
64135
throwIfAborted(context.signal);
65136
const plaintextKey = generateKeySync("aes", { length: 256 });
66-
const wrappedKey = this.#wrap(plaintextKey, this.#keys.get(this.#activeKeyId)!, context);
137+
const wrappedKey = this.#wrap(
138+
plaintextKey,
139+
this.#keys.get(this.#activeKeyId)!,
140+
this.#activeKeyId,
141+
context,
142+
);
67143
throwIfAborted(context.signal);
68144
return Object.freeze({
69145
plaintextKey,
70146
wrappedKey,
71147
keyReference: this.#activeKeyId,
72-
wrappingAlgorithm: AES_GCM_KEY_WRAP,
148+
wrappingAlgorithm: AES_GCM_HKDF_SHA256_KEY_WRAP,
73149
});
74150
}
75151

76152
async unwrapDataKey(dataKey: WrappedDataKey, context: DataKeyContext): Promise<KeyObject> {
77153
throwIfAborted(context.signal);
78-
if (dataKey.wrappingAlgorithm !== AES_GCM_KEY_WRAP) {
154+
if (
155+
dataKey.wrappingAlgorithm !== AES_GCM_HKDF_SHA256_KEY_WRAP &&
156+
dataKey.wrappingAlgorithm !== AES_GCM_KEY_WRAP
157+
) {
79158
throw new CryptoError("INVALID_KEY", "The wrapped-key algorithm is unsupported.");
80159
}
81160
const kek = this.#keys.get(dataKey.keyReference);
82161
if (!kek) throw new CryptoError("KEY_NOT_FOUND", "The wrapping key was not found.");
83-
if (
84-
dataKey.wrappedKey.byteLength !== WRAPPED_LENGTH ||
85-
dataKey.wrappedKey[0] !== WRAP_VERSION
86-
) {
162+
if (dataKey.wrappingAlgorithm === AES_GCM_HKDF_SHA256_KEY_WRAP) {
163+
return this.#unwrapSalted(dataKey.wrappedKey, kek, dataKey.keyReference, context);
164+
}
165+
return this.#unwrapLegacy(dataKey.wrappedKey, kek, context);
166+
}
167+
168+
#unwrapSalted(
169+
wrappedKey: Uint8Array,
170+
kek: KeyObject,
171+
keyReference: string,
172+
context: DataKeyContext,
173+
): KeyObject {
174+
if (wrappedKey.byteLength !== SALTED_WRAPPED_LENGTH || wrappedKey[0] !== SALTED_WRAP_VERSION) {
87175
throw authenticationFailed();
88176
}
177+
const salt = wrappedKey.subarray(1, 1 + SALT_BYTES);
178+
const ciphertext = wrappedKey.subarray(1 + SALT_BYTES, 1 + SALT_BYTES + DATA_KEY_BYTES);
179+
const tag = wrappedKey.subarray(1 + SALT_BYTES + DATA_KEY_BYTES);
180+
let wrappingKey: Buffer | undefined;
89181
try {
90-
const nonce = dataKey.wrappedKey.subarray(1, 13);
91-
const ciphertext = dataKey.wrappedKey.subarray(13, 45);
92-
const tag = dataKey.wrappedKey.subarray(45);
182+
wrappingKey = deriveOneUseWrappingKey(kek, salt, keyReference, context.wrappingContext);
183+
const decipher = createDecipheriv("aes-256-gcm", wrappingKey, FIXED_WRAP_IV, {
184+
authTagLength: TAG_BYTES,
185+
});
186+
decipher.setAAD(WRAP_AUTHENTICATED_DATA, { plaintextLength: DATA_KEY_BYTES });
187+
decipher.setAuthTag(tag);
188+
return decryptDataKey(decipher, ciphertext);
189+
} catch (error: unknown) {
190+
if (error instanceof CryptoError) throw error;
191+
throw authenticationFailed({ cause: error });
192+
} finally {
193+
wrappingKey?.fill(0);
194+
}
195+
}
196+
197+
#unwrapLegacy(wrappedKey: Uint8Array, kek: KeyObject, context: DataKeyContext): KeyObject {
198+
if (wrappedKey.byteLength !== LEGACY_WRAPPED_LENGTH || wrappedKey[0] !== LEGACY_WRAP_VERSION) {
199+
throw authenticationFailed();
200+
}
201+
try {
202+
const nonce = wrappedKey.subarray(1, 13);
203+
const ciphertext = wrappedKey.subarray(13, 45);
204+
const tag = wrappedKey.subarray(45);
93205
const decipher = createDecipheriv("aes-256-gcm", kek, nonce, { authTagLength: 16 });
94206
decipher.setAAD(context.wrappingContext, { plaintextLength: 32 });
95207
decipher.setAuthTag(tag);
96-
const first = decipher.update(ciphertext);
97-
let last: Buffer | undefined;
98-
let raw: Buffer | undefined;
99-
try {
100-
last = decipher.final();
101-
raw = Buffer.concat([first, last]);
102-
if (raw.byteLength !== 32) throw authenticationFailed();
103-
return createSecretKey(raw);
104-
} finally {
105-
first.fill(0);
106-
last?.fill(0);
107-
raw?.fill(0);
108-
}
208+
return decryptDataKey(decipher, ciphertext);
109209
} catch (error: unknown) {
110210
if (error instanceof CryptoError) throw error;
111211
throw authenticationFailed({ cause: error });
112212
}
113213
}
114214

115-
#wrap(key: KeyObject, kek: KeyObject, context: DataKeyContext): Uint8Array {
116-
const nonce = randomBytes(12);
215+
#wrap(key: KeyObject, kek: KeyObject, keyReference: string, context: DataKeyContext): Uint8Array {
216+
const salt = randomBytes(SALT_BYTES);
117217
const raw = key.export();
218+
let wrappingKey: Buffer | undefined;
219+
let ciphertext: Buffer | undefined;
220+
let tag: Buffer | undefined;
118221
try {
119-
const cipher = createCipheriv("aes-256-gcm", kek, nonce, { authTagLength: 16 });
120-
cipher.setAAD(context.wrappingContext, { plaintextLength: raw.byteLength });
121-
const ciphertext = Buffer.concat([cipher.update(raw), cipher.final()]);
122-
const output = new Uint8Array(WRAPPED_LENGTH);
123-
output[0] = WRAP_VERSION;
124-
output.set(nonce, 1);
125-
output.set(ciphertext, 13);
126-
output.set(cipher.getAuthTag(), 45);
222+
wrappingKey = deriveOneUseWrappingKey(kek, salt, keyReference, context.wrappingContext);
223+
const cipher = createCipheriv("aes-256-gcm", wrappingKey, FIXED_WRAP_IV, {
224+
authTagLength: TAG_BYTES,
225+
});
226+
cipher.setAAD(WRAP_AUTHENTICATED_DATA, { plaintextLength: DATA_KEY_BYTES });
227+
ciphertext = Buffer.concat([cipher.update(raw), cipher.final()]);
228+
if (ciphertext.byteLength !== DATA_KEY_BYTES) {
229+
throw new CryptoError("CIPHER_FAILURE", "Key wrapping produced an invalid result.");
230+
}
231+
tag = cipher.getAuthTag();
232+
const output = new Uint8Array(SALTED_WRAPPED_LENGTH);
233+
output[0] = SALTED_WRAP_VERSION;
234+
output.set(salt, 1);
235+
output.set(ciphertext, 1 + SALT_BYTES);
236+
output.set(tag, 1 + SALT_BYTES + DATA_KEY_BYTES);
127237
return output;
238+
} catch (error: unknown) {
239+
if (error instanceof CryptoError) throw error;
240+
throw new CryptoError("CIPHER_FAILURE", "Key wrapping failed.", { cause: error });
128241
} finally {
242+
wrappingKey?.fill(0);
243+
ciphertext?.fill(0);
244+
tag?.fill(0);
129245
raw.fill(0);
130246
}
131247
}

src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { Aes256GcmCipher, AES_256_GCM } from "./aes-256-gcm.js";
22
export {
33
AesKeyRingProvider,
4+
AES_GCM_HKDF_SHA256_KEY_WRAP,
45
AES_GCM_KEY_WRAP,
56
type AesKeyRingProviderOptions,
67
} from "./aes-key-ring.provider.js";

tests/unit/files-engine.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,10 +600,11 @@ describe("FileCipherEngine", () => {
600600
expect(
601601
new Set(
602602
attempts.map((attempt) =>
603-
Buffer.from(attempt.detachedKey.wrappedKey.subarray(1, 13)).toString("hex"),
603+
Buffer.from(attempt.detachedKey.wrappedKey.subarray(1, 33)).toString("hex"),
604604
),
605605
).size,
606606
).toBe(3);
607+
for (const attempt of attempts) expect(attempt.detachedKey.wrappedKey).toHaveLength(81);
607608
expect(
608609
new Set(
609610
attempts.map((attempt) => Buffer.from(attempt.detachedKey.wrappedKey).toString("hex")),

0 commit comments

Comments
 (0)