Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fresh-only-key-wrappers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@nestm/crypto": minor
---

Make the local AES key ring fresh-format only: unwrap accepts exactly the 81-byte salted version 2
wrapper and its `NESTM-A256GCM-HKDF-SHA256-SALT256-V2` identifier. Remove the deprecated version 1
algorithm export and compatibility path; development data using another wrapper format must be reset.
7 changes: 3 additions & 4 deletions .changeset/quiet-wrappers-simplify.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"@nestm/crypto": minor
---

Derive a one-use AES-256-GCM wrapping key with HKDF-SHA256 and a fresh 256-bit salt for every new
local key-ring data key while retaining read-only compatibility with legacy A256GCMKW envelopes.
Version 2 removes the need for a durable global wrapper-invocation counter and keeps 128-bit
authentication.
Derive a one-use AES-256-GCM wrapping key with HKDF-SHA256 and a fresh 256-bit salt for every local
key-ring data key. Version 2 removes the need for a durable global wrapper-invocation counter and
keeps 128-bit authentication.
7 changes: 3 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@

### Minor Changes

- bc22fed: Derive a one-use AES-256-GCM wrapping key with HKDF-SHA256 and a fresh 256-bit salt for every new
local key-ring data key while retaining read-only compatibility with legacy A256GCMKW envelopes.
Version 2 removes the need for a durable global wrapper-invocation counter and keeps 128-bit
authentication.
- bc22fed: Derive a one-use AES-256-GCM wrapping key with HKDF-SHA256 and a fresh 256-bit salt for every
local key-ring data key. Version 2 removes the need for a durable global wrapper-invocation counter
and keeps 128-bit authentication.

## 0.1.0-alpha.3

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,12 @@ The `nmc1` format constrains registered ciphers to a 12-byte nonce so batch encr
final four bytes for a collision-free operation counter. The cipher/provider contracts are extensible,
but an algorithm needing a different nonce construction requires a future envelope version.

`AesKeyRingProvider` derives a one-use AES-256-GCM wrapping key from the long-lived key, a fresh
`AesKeyRingProvider` derives a one-use AES-256-GCM wrapping key from the configured key, a fresh
256-bit random salt, and domain-separated HKDF-SHA256 info bound to the key reference and wrapping
context. The one-use key makes the format's fixed 96-bit GCM nonce safe without a durable invocation
counter. New wrappers are 81 bytes (`version || salt || ciphertext || tag`), tagged version 2, and
report `NESTM-A256GCM-HKDF-SHA256-SALT256-V2`; the 61-byte version 1 `A256GCMKW` wrappers written
previously stay readable, so no stored key needs rewrapping.
counter. Wrappers are exactly 81 bytes (`version || salt || ciphertext || tag`), tagged version 2,
and report `NESTM-A256GCM-HKDF-SHA256-SALT256-V2`. Any other algorithm, size, or version is rejected;
development data written with another format must be reset.

Wrapping-key rotation does not require immediately rewriting every value: keep old keys in the local
ring as decrypt-only entries, or keep an old named provider in `allowedProviders`. Use `reencrypt()` to
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ backup/restore policy, and deciding which data must be encrypted.
long-lived key and a fresh 256-bit salt. Domain-separated derivation info binds the version, key
reference, and a length-framed digest of the wrapping context. Each one-use key encrypts exactly
one 32-byte data key with a fixed 96-bit IV and a 128-bit tag. Salt collision probability replaces
the durable global counter previously required to prove direct GCM nonce uniqueness. Envelopes
written before this construction remain readable under their `A256GCMKW` algorithm name and
version byte.
the durable global counter otherwise required to prove direct GCM nonce uniqueness. The local
wrapper accepts only the 81-byte version 2 format and its exact
`NESTM-A256GCM-HKDF-SHA256-SALT256-V2` algorithm identifier.
- Plaintext data keys are held as `KeyObject` values as early as practical. Temporary byte buffers are
zeroed best-effort, but JavaScript runtimes cannot guarantee erasure of every copy.
- No persistent plaintext data-key cache is part of the design.
Expand Down
70 changes: 54 additions & 16 deletions scripts/verify-nmf1-vectors.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import assert from "node:assert/strict";
import { createCipheriv, createDecipheriv, createHash } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, hkdfSync } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
Expand All @@ -11,6 +11,20 @@ const FILE_HEADER_BYTES = 52;
const DATA_HEADER_BYTES = 12;
const FINAL_HEADER_BYTES = 16;
const TAG_BYTES = 16;
const WRAP_VERSION = 2;
const WRAP_SALT_BYTES = 32;
const WRAPPED_KEY_BYTES = 1 + WRAP_SALT_BYTES + 32 + TAG_BYTES;
const WRAP_FIXED_IV = Buffer.alloc(12);
const KEY_DERIVATION_INFO = Buffer.from(
"nestm:aes-key-ring:a256gcm-hkdf-sha256-salt256:v2\0",
"utf8",
);
const KEY_REFERENCE_CONTEXT = "nestm:aes-key-ring:key-reference:v2\0";
const WRAPPING_CONTEXT = "nestm:aes-key-ring:wrapping-context:v2\0";
const WRAP_AAD = Buffer.concat([
Buffer.from("nestm:aes-key-ring:wrapped-data-key:v2\0", "utf8"),
Buffer.of(WRAP_VERSION),
]);
const FILE_AAD_DIGEST_PREFIX = Buffer.from("nestm:nmf1:file-aad-digest:v1\0", "utf8");
const FRAME_AAD_PREFIX = Buffer.from("nestm:nmf1:frame-aad:v1\0", "utf8");
const KEY_CONTEXT_PREFIX = Buffer.from("nestm:nmf1:file-key-context:v1\0", "utf8");
Expand Down Expand Up @@ -82,6 +96,38 @@ function digest(...parts) {
return hash.digest();
}

function framedDigest(domain, value) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(value.byteLength));
return digest(Buffer.from(domain, "utf8"), length, value);
}

function deriveWrappingKey(domainKey, salt, keyReference, wrappingContext) {
const info = Buffer.concat([
KEY_DERIVATION_INFO,
Buffer.of(WRAP_VERSION),
framedDigest(KEY_REFERENCE_CONTEXT, Buffer.from(keyReference, "utf8")),
framedDigest(WRAPPING_CONTEXT, wrappingContext),
]);
return Buffer.from(hkdfSync("sha256", domainKey, salt, info, 32));
}

function wrapDataKey(domainKey, salt, keyReference, wrappingContext, dataKey) {
const wrappingKey = deriveWrappingKey(domainKey, salt, keyReference, wrappingContext);
const ciphertextAndTag = encryptAesGcm(wrappingKey, WRAP_FIXED_IV, dataKey, WRAP_AAD);
return Buffer.concat([Buffer.of(WRAP_VERSION), salt, ciphertextAndTag]);
}

function unwrapDataKey(domainKey, keyReference, wrappingContext, wrappedKey) {
assert.equal(wrappedKey.byteLength, WRAPPED_KEY_BYTES, "detached wrapper must be 81 bytes");
assert.equal(wrappedKey[0], WRAP_VERSION, "detached wrapper version must be two");
const salt = wrappedKey.subarray(1, 1 + WRAP_SALT_BYTES);
const ciphertext = wrappedKey.subarray(1 + WRAP_SALT_BYTES, WRAPPED_KEY_BYTES - TAG_BYTES);
const tag = wrappedKey.subarray(WRAPPED_KEY_BYTES - TAG_BYTES);
const wrappingKey = deriveWrappingKey(domainKey, salt, keyReference, wrappingContext);
return decryptAesGcm(wrappingKey, WRAP_FIXED_IV, ciphertext, tag, WRAP_AAD);
}

function encryptAesGcm(key, nonce, plaintext, aad) {
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
cipher.setAAD(aad, { plaintextLength: plaintext.byteLength });
Expand Down Expand Up @@ -112,18 +158,17 @@ export function buildDeterministicVector(input, options = {}) {
const fileAad = parseHex(input.fileAadHex, "input.fileAadHex");
const dek = parseHex(input.dekHex, "input.dekHex");
const noncePrefix = parseHex(input.noncePrefixHex, "input.noncePrefixHex");
const wrapperNonce = parseHex(input.wrapperNonceHex, "input.wrapperNonceHex");
const wrapperSalt = parseHex(input.wrapperSaltHex, "input.wrapperSaltHex");
const domainKey = parseHex(input.domainKeyHex, "input.domainKeyHex");
assert.equal(dek.byteLength, 32, "fixture DEK must be 32 bytes");
assert.equal(noncePrefix.byteLength, 8, "fixture nonce prefix must be 8 bytes");
assert.equal(wrapperNonce.byteLength, 12, "fixture wrapper nonce must be 12 bytes");
assert.equal(wrapperSalt.byteLength, WRAP_SALT_BYTES, "fixture wrapper salt must be 32 bytes");
assert.equal(domainKey.byteLength, 32, "fixture domain key must be 32 bytes");

const contextDigest = digest(FILE_AAD_DIGEST_PREFIX, lp(fileAad));
const header = Buffer.concat([CONSTANT_FILE_HEADER, noncePrefix, contextDigest]);
const wrappingContext = Buffer.concat([KEY_CONTEXT_PREFIX, lp(fileAad), lp(header)]);
const wrappedDek = encryptAesGcm(domainKey, wrapperNonce, dek, wrappingContext);
const wrappedKey = Buffer.concat([Buffer.of(1), wrapperNonce, wrappedDek]);
const wrappedKey = wrapDataKey(domainKey, wrapperSalt, input.keyReference, wrappingContext, dek);
const frames = collectBytes ? [header] : undefined;
const ciphertextHash = createHash("sha256");
ciphertextHash.update(header);
Expand Down Expand Up @@ -204,22 +249,14 @@ export function buildDeterministicVector(input, options = {}) {
};
}

function independentlyDecrypt(nmf1, fileAad, wrappedKey, domainKey) {
function independentlyDecrypt(nmf1, fileAad, wrappedKey, domainKey, keyReference) {
assert.ok(nmf1.byteLength >= FILE_HEADER_BYTES, "truncated NMF1 file header");
const header = nmf1.subarray(0, FILE_HEADER_BYTES);
assert.deepEqual(header.subarray(0, 12), CONSTANT_FILE_HEADER, "non-canonical NMF1 header");
const expectedContextDigest = digest(FILE_AAD_DIGEST_PREFIX, lp(fileAad));
assert.deepEqual(header.subarray(20), expectedContextDigest, "file AAD digest mismatch");
assert.equal(wrappedKey.byteLength, 61, "detached wrapper must be 61 bytes");
assert.equal(wrappedKey[0], 1, "detached wrapper version must be one");
const wrappingContext = Buffer.concat([KEY_CONTEXT_PREFIX, lp(fileAad), lp(header)]);
const dek = decryptAesGcm(
domainKey,
wrappedKey.subarray(1, 13),
wrappedKey.subarray(13, 45),
wrappedKey.subarray(45),
wrappingContext,
);
const dek = unwrapDataKey(domainKey, keyReference, wrappingContext, wrappedKey);
const noncePrefix = header.subarray(12, 20);
const plaintext = [];
let offset = FILE_HEADER_BYTES;
Expand Down Expand Up @@ -301,7 +338,7 @@ export function verifyNmf1Fixture(fixture) {
assert.equal(expected.detachedKeyVersion, 1);
assert.equal(expected.detachedKeyProvider, fixture.input.provider);
assert.equal(expected.detachedKeyReference, fixture.input.keyReference);
assert.equal(expected.wrappingAlgorithm, "A256GCMKW");
assert.equal(expected.wrappingAlgorithm, "NESTM-A256GCM-HKDF-SHA256-SALT256-V2");
assert.equal(built.wrappedKey.toString("hex"), expected.wrappedKeyHex, "wrapped DEK differs");
assert.equal(String(built.plaintextBytes), expected.plaintextBytes);
assert.equal(String(built.ciphertextBytes), expected.ciphertextBytes);
Expand All @@ -317,6 +354,7 @@ export function verifyNmf1Fixture(fixture) {
built.fileAad,
parseHex(expected.wrappedKeyHex, "expected.wrappedKeyHex"),
parseHex(fixture.input.domainKeyHex, "input.domainKeyHex"),
fixture.input.keyReference,
),
built.plaintext,
"independent decrypt did not recover the fixture plaintext",
Expand Down
50 changes: 11 additions & 39 deletions src/core/aes-key-ring.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,22 @@ import {
import { authenticationFailed, CryptoError, throwIfAborted } from "./errors.js";
import type { DataKeyContext, DataKeyProvider, GeneratedDataKey, WrappedDataKey } from "./types.js";

/** Salt-derived, one-use AES-256-GCM key wrapping used for new local key-ring writes. */
/** Salt-derived, one-use AES-256-GCM key wrapping used by the local key ring. */
export const AES_GCM_HKDF_SHA256_KEY_WRAP = "NESTM-A256GCM-HKDF-SHA256-SALT256-V2";
/** @deprecated Read-only compatibility for envelopes written before AES_GCM_HKDF_SHA256_KEY_WRAP. */
export const AES_GCM_KEY_WRAP = "A256GCMKW";

const SALTED_WRAP_VERSION = 2;
const WRAP_VERSION = 2;
const SALT_BYTES = 32;
const DATA_KEY_BYTES = 32;
const TAG_BYTES = 16;
const SALTED_WRAPPED_LENGTH = 1 + SALT_BYTES + DATA_KEY_BYTES + TAG_BYTES;
const WRAPPED_KEY_BYTES = 1 + SALT_BYTES + DATA_KEY_BYTES + TAG_BYTES;
const FIXED_WRAP_IV = new Uint8Array(12);
const KEY_DERIVATION_INFO = "nestm:aes-key-ring:a256gcm-hkdf-sha256-salt256:v2\0";
const KEY_REFERENCE_CONTEXT = "nestm:aes-key-ring:key-reference:v2\0";
const WRAPPING_CONTEXT = "nestm:aes-key-ring:wrapping-context:v2\0";
const WRAP_AUTHENTICATED_DATA = Buffer.concat([
Buffer.from("nestm:aes-key-ring:wrapped-data-key:v2\0", "utf8"),
Buffer.of(SALTED_WRAP_VERSION),
Buffer.of(WRAP_VERSION),
]);
const LEGACY_WRAP_VERSION = 1;
const LEGACY_WRAPPED_LENGTH = 1 + 12 + 32 + 16;

export interface AesKeyRingProviderOptions {
readonly activeKeyId: string;
Expand Down Expand Up @@ -83,7 +79,7 @@ function deriveOneUseWrappingKey(
const contextDigest = framedDigest(WRAPPING_CONTEXT, wrappingContext);
const info = Buffer.concat([
Buffer.from(KEY_DERIVATION_INFO, "utf8"),
Buffer.of(SALTED_WRAP_VERSION),
Buffer.of(WRAP_VERSION),
referenceDigest,
contextDigest,
]);
Expand Down Expand Up @@ -151,27 +147,21 @@ export class AesKeyRingProvider implements DataKeyProvider {

async unwrapDataKey(dataKey: WrappedDataKey, context: DataKeyContext): Promise<KeyObject> {
throwIfAborted(context.signal);
if (
dataKey.wrappingAlgorithm !== AES_GCM_HKDF_SHA256_KEY_WRAP &&
dataKey.wrappingAlgorithm !== AES_GCM_KEY_WRAP
) {
if (dataKey.wrappingAlgorithm !== AES_GCM_HKDF_SHA256_KEY_WRAP) {
throw new CryptoError("INVALID_KEY", "The wrapped-key algorithm is unsupported.");
}
const kek = this.#keys.get(dataKey.keyReference);
if (!kek) throw new CryptoError("KEY_NOT_FOUND", "The wrapping key was not found.");
if (dataKey.wrappingAlgorithm === AES_GCM_HKDF_SHA256_KEY_WRAP) {
return this.#unwrapSalted(dataKey.wrappedKey, kek, dataKey.keyReference, context);
}
return this.#unwrapLegacy(dataKey.wrappedKey, kek, context);
return this.#unwrap(dataKey.wrappedKey, kek, dataKey.keyReference, context);
}

#unwrapSalted(
#unwrap(
wrappedKey: Uint8Array,
kek: KeyObject,
keyReference: string,
context: DataKeyContext,
): KeyObject {
if (wrappedKey.byteLength !== SALTED_WRAPPED_LENGTH || wrappedKey[0] !== SALTED_WRAP_VERSION) {
if (wrappedKey.byteLength !== WRAPPED_KEY_BYTES || wrappedKey[0] !== WRAP_VERSION) {
throw authenticationFailed();
}
const salt = wrappedKey.subarray(1, 1 + SALT_BYTES);
Expand All @@ -194,24 +184,6 @@ export class AesKeyRingProvider implements DataKeyProvider {
}
}

#unwrapLegacy(wrappedKey: Uint8Array, kek: KeyObject, context: DataKeyContext): KeyObject {
if (wrappedKey.byteLength !== LEGACY_WRAPPED_LENGTH || wrappedKey[0] !== LEGACY_WRAP_VERSION) {
throw authenticationFailed();
}
try {
const nonce = wrappedKey.subarray(1, 13);
const ciphertext = wrappedKey.subarray(13, 45);
const tag = wrappedKey.subarray(45);
const decipher = createDecipheriv("aes-256-gcm", kek, nonce, { authTagLength: 16 });
decipher.setAAD(context.wrappingContext, { plaintextLength: 32 });
decipher.setAuthTag(tag);
return decryptDataKey(decipher, ciphertext);
} catch (error: unknown) {
if (error instanceof CryptoError) throw error;
throw authenticationFailed({ cause: error });
}
}

#wrap(key: KeyObject, kek: KeyObject, keyReference: string, context: DataKeyContext): Uint8Array {
const salt = randomBytes(SALT_BYTES);
const raw = key.export();
Expand All @@ -229,8 +201,8 @@ export class AesKeyRingProvider implements DataKeyProvider {
throw new CryptoError("CIPHER_FAILURE", "Key wrapping produced an invalid result.");
}
tag = cipher.getAuthTag();
const output = new Uint8Array(SALTED_WRAPPED_LENGTH);
output[0] = SALTED_WRAP_VERSION;
const output = new Uint8Array(WRAPPED_KEY_BYTES);
output[0] = WRAP_VERSION;
output.set(salt, 1);
output.set(ciphertext, 1 + SALT_BYTES);
output.set(tag, 1 + SALT_BYTES + DATA_KEY_BYTES);
Expand Down
1 change: 0 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ export { Aes256GcmCipher, AES_256_GCM } from "./aes-256-gcm.js";
export {
AesKeyRingProvider,
AES_GCM_HKDF_SHA256_KEY_WRAP,
AES_GCM_KEY_WRAP,
type AesKeyRingProviderOptions,
} from "./aes-key-ring.provider.js";
export {
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/files-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface Latch {
const DEFAULT_AAD = Uint8Array.from([0x10, 0x20, 0x30]);
const DEFAULT_NONCE_PREFIX = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
const DEFAULT_DEK = Uint8Array.from({ length: 32 }, (_, index) => index + 1);
const DEFAULT_WRAPPER = Uint8Array.from({ length: 61 }, (_, index) => (index * 7 + 3) & 0xff);
const DEFAULT_WRAPPER = Uint8Array.from({ length: 81 }, (_, index) => (index * 7 + 3) & 0xff);

function bytes(hex: string): Uint8Array {
return new Uint8Array(Buffer.from(hex, "hex"));
Expand Down
Loading
Loading