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
41 changes: 41 additions & 0 deletions .changeset/zero-access-key-hierarchy-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@nestm/crypto": minor
---

Add the primitives a zero-access key hierarchy needs: password-derived key-encryption keys,
recipient-addressed key-wrap records, and a chunked streaming file format.

- `@nestm/crypto/password`: a registry-based `PasswordKdf` (`createPasswordKdf`,
`scryptPasswordKdf`) deriving a 32-byte `KeyObject` from a login password via scrypt over
`node:crypto` — no native dependency, versioned parameters stored through a canonical
`encodePasswordKdfParams`/`decodePasswordKdfParams` codec, an explicit `maxmem`, and a FIFO
semaphore bounding concurrent memory-hard derivations. Also ships display-once recovery
codes (`generateRecoveryCode`, `parseRecoveryCode`, `deriveRecoveryKey`) as Crockford
base32 in uniform eight-character groups — `ASR1-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX` for a
128-bit secret — carrying a checksum wide enough to reject every single-character
corruption, parsed fail-closed.
- `@nestm/crypto/keys`: `KeyWrapRecord` — one versioned record type covering both
`wrapKeyWithSecret` (AES-256-GCM under a shared KEK) and `wrapKeyToRecipient` (sealed to an
X25519 public key), with encode/decode for single records and lists, `selectKeyWrapRecord`,
and `keyWrapRecordLength`. Both flavours authenticate the algorithm, recipient type, and
recipient identifier, so a record cannot be replayed against a different recipient. Adds
`hmacSha256` and a length-tolerant `timingSafeEqualBytes`.
- `@nestm/crypto/stream`: the `nmcs1` container — a fixed 512-byte authenticated header
(magic, suite, chunk size, per-object random file identifier, key reference, context
associated data, and optional inline wrap records) followed by AES-256-GCM chunks framed as
`u32BE length ‖ ciphertext ‖ tag`. Per-chunk keys and nonce prefixes derive from
HKDF-SHA256 over the data key, the file identifier, and a hash of the whole header, so no
nonce counter is ever persisted and a rolled-back database cannot cause nonce reuse. The
final chunk is flagged in both its nonce and its associated data, which makes truncation,
reordering, and cross-object splicing fail authentication. Buffered `sealChunked`/
`openChunked` and Web-Streams `createChunkedSealStream`/`createChunkedOpenStream` produce
identical bytes when the plaintext length is declared (a stream that cannot know its length
clears the declared-length flag instead), and the fixed header makes plaintext size exactly
recoverable from ciphertext size via `chunkedPlaintextLength`. Sealing and opening share one
default chunk-size ceiling of `2^24`, so the library never writes an object its own default
reader would refuse; `2^25` and `2^26` require `maxChunkSizeLog2` on both sides. The
per-object file identifier is never caller-supplied — pinning it is possible only through
`@nestm/crypto/testing`, because reusing one under the same data key would repeat a
keystream.
- `@nestm/crypto/core` now exports `isCipherEnvelope` for routing legacy plaintext during a
migration. The envelope parser stays internal.
147 changes: 147 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,150 @@ function createTenantPrismaFieldEncryption(
): TenantPrismaWriteProcessor;
```

## Key hierarchies

`@nestm/crypto/keys`, `@nestm/crypto/password`, and `@nestm/crypto/stream` are framework-neutral
building blocks for key hierarchies where the server holds no long-lived master key: a password
unlocks a user key, user keys hold grants to shared keys, and shared keys protect per-object data
keys. They are independent of `CipherEngine` and `DataKeyProvider`.

### Asymmetric keys and sealed boxes — `@nestm/crypto/keys`

```ts
import { generateX25519KeyPair, sealTo, openKeyFrom, hkdfSha256 } from "@nestm/crypto/keys";

const recipient = generateX25519KeyPair();
// Sealing needs only the public key, so a writer never needs the recipient online.
const sealed = sealTo(recipient.publicKey, dataKey, { info: "wsk.seal", aad: "epoch:1" });
const recovered = openKeyFrom(recipient.privateKey, sealed, { info: "wsk.seal", aad: "epoch:1" });
```

`sealTo` uses ephemeral-static X25519 → HKDF-SHA256 → AES-256-GCM, binds the recipient public key
into the key schedule, derives the nonce (never transmitting it), and caps payloads at 64 KiB — it
is a key-wrapping primitive, not a data cipher.

### Key-wrap records

A single versioned record covers both wrapping flavours, so a grant table or a file header can
carry a heterogeneous list of them:

```ts
import {
wrapKeyWithSecret,
wrapKeyToRecipient,
encodeKeyWrapRecords,
decodeKeyWrapRecords,
selectKeyWrapRecord,
unwrapKeyFromRecipient,
} from "@nestm/crypto/keys";

const grants = [
wrapKeyToRecipient(alicePublicKey, workspaceKey, { recipientId: "user:alice" }),
wrapKeyWithSecret(orgKek, workspaceKey, { recipientId: "org:acme" }),
];
const stored = encodeKeyWrapRecords(grants);

const mine = selectKeyWrapRecord(decodeKeyWrapRecords(stored), {
recipientType: "x25519",
recipientId: "user:alice",
});
const workspaceKeyAgain = unwrapKeyFromRecipient(alicePrivateKey, mine!);
```

The algorithm, recipient type, and recipient identifier are authenticated, so a record cannot be
retargeted at a different recipient even under the same wrapping key.

### Password-derived keys — `@nestm/crypto/password`

```ts
import {
createPasswordKdf,
generatePasswordSalt,
encodePasswordKdfParams,
PASSWORD_KDF_SCRYPT_DEFAULT,
} from "@nestm/crypto/password";

const kdf = createPasswordKdf(); // scrypt N=2^16, r=8, p=1; at most 4 concurrent derivations
const salt = generatePasswordSalt();
const kek = await kdf.derive({
password,
salt,
kdf: PASSWORD_KDF_SCRYPT_DEFAULT,
info: "umk.password", // domain separation; the same password yields unrelated keys per purpose
});
// Persist the salt and encodePasswordKdfParams(PASSWORD_KDF_SCRYPT_DEFAULT) beside the wrap.
```

Parameters are versioned per record, so a stored key can be re-wrapped under stronger parameters
later without a format change. Registering an Argon2id implementation of `PasswordKdfAlgorithm`
requires no format change either; the default stays dependency-free.

Recovery codes are full-entropy secrets, so they derive through HKDF rather than a memory-hard KDF:

```ts
import { generateRecoveryCode, parseRecoveryCode, deriveRecoveryKey } from "@nestm/crypto/password";

// ASR1-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX — display once, never store the code itself
const { code, secret } = generateRecoveryCode();
const recoveryKek = deriveRecoveryKey(secret, recoverySalt, "umk.recovery");
// later
const recoveryKekAgain = deriveRecoveryKey(
parseRecoveryCode(userInput),
recoverySalt,
"umk.recovery",
);
```

Store only the wrap the code produces. Persisting a verifier hash would hand an offline oracle to
anyone who reads the database.

### Chunked files — `@nestm/crypto/stream`

`nmcs1` is a self-contained container: a fixed 512-byte authenticated header followed by
AES-256-GCM chunks (1 MiB by default, 20 bytes of framing overhead each).

```ts
import { sealChunked, openChunked, inspectChunked } from "@nestm/crypto/stream";

const sealed = sealChunked(dataKey, plaintext, {
keyReference: "ws:1f9e:e1",
aad: "org:acme|ws:1f9e", // binds the object to its location; a relocated copy fails to open
wrapRecords: [wrapKeyToRecipient(workspacePublicKey, dataKey, { recipientId: "ws:1f9e" })],
});

const header = inspectChunked(sealed); // framing metadata only — nothing is authenticated yet
const plaintextAgain = openChunked(dataKey, sealed, { aad: "org:acme|ws:1f9e" });
```

Large payloads use the Web-Streams form, which is byte-identical to the buffered form when
`plaintextLength` is declared (`sealChunked` always knows the length; a stream does not, so an
undeclared stream records the length as absent):

```ts
import { createChunkedSealStream, createChunkedOpenStream } from "@nestm/crypto/stream";

await source.pipeThrough(createChunkedSealStream(dataKey, { keyReference })).pipeTo(destination);
await ciphertext.pipeThrough(createChunkedOpenStream(dataKey)).pipeTo(sink);
```

`createChunkedOpenStream` emits an **authenticated prefix**: every chunk is verified before it is
emitted, but truncation is only detected at end of stream. Consumers that must not act on a partial
payload should use `openChunked`.

Because the header is fixed-size and every chunk but the last is full, sizes convert exactly in both
directions without reading the object — useful for reporting plaintext sizes from a storage listing:

```ts
import { chunkedCiphertextLength, chunkedPlaintextLength } from "@nestm/crypto/stream";

chunkedCiphertextLength(1_234); // 1_766
chunkedPlaintextLength(1_766); // 1_234 (undefined when the size is not one the format can produce)
```

Nonces are derived per chunk from the data key, a per-object random file identifier, and a hash of
the header; no counter is persisted anywhere, so restoring a database to an earlier point in time
cannot cause nonce reuse.

## `nestjs-field-encryption` compatibility map

This package now covers the integration surfaces demonstrated by
Expand Down Expand Up @@ -595,6 +739,9 @@ plaintext with `@nestm/crypto`; relabeling or importing the old ciphertext is no
| Entry point | Purpose |
| ---------------------------- | ----------------------------------------------------------------------------- |
| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors |
| `@nestm/crypto/keys` | X25519 keys, HKDF-SHA256, sealed boxes, key-wrap records, HMAC helpers |
| `@nestm/crypto/password` | password-derived key-encryption keys and display-once recovery codes |
| `@nestm/crypto/stream` | `nmcs1` chunked AES-256-GCM container for buffered and streaming payloads |
| `@nestm/crypto/fields` | purpose-decorated class traversal |
| `@nestm/crypto/tenant` | tenant-bound cipher and field services |
| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor |
Expand Down
81 changes: 79 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,83 @@ different row that uses the same purpose in the same tenant. Applications that r
must use an explicit service boundary with stable row-specific AAD; a future Prisma AAD-resolver API
would need to define update/upsert identity and migration behavior before offering that guarantee.

## Chunked stream boundary

`@nestm/crypto/stream` (`nmcs1`) protects whole objects, buffered or streamed.

- The 512-byte header is authenticated by its own tag and hashed into the chunk key schedule, so a
change to the key reference, chunk size, context associated data, or inline wrap records makes
every chunk fail authentication. Header padding must be zero and reserved flag bits must be clear.
- Each chunk's nonce is `noncePrefix ‖ chunkIndex ‖ finalFlag`, where the prefix derives from the
data key and a per-object random 16-byte file identifier. **No nonce input is ever persisted or
read back from storage.** A counter written to a database and later restored to an earlier point
in time — by point-in-time recovery, a replica promotion, or a restored backup — would reissue
nonces under the same key and break AES-GCM catastrophically. Any future change to this format
must preserve that property: nonces come from a CSPRNG draw or from an HKDF over per-object
random material, never from durable mutable state.
- The final chunk is flagged in both its nonce and its associated data, so truncation, chunk
reordering, and splicing chunks between objects all fail authentication rather than yielding a
short or wrong plaintext.
- `createChunkedOpenStream` emits an **authenticated prefix**. Each chunk is verified before it is
emitted, but truncation is only detectable at end of stream, so a consumer acting on partial
output may act on a prefix of the plaintext. Use `openChunked` where all-or-nothing semantics are
required, and treat a stream that errors mid-flight as having produced nothing.
- `inspectChunked` reports framing only and marks itself `authenticated: false`. Never make a trust
decision on its output.
- The context associated data in the header is **not encrypted**. Bind location and ownership there
(organization, workspace, object key); never put secrets in it.
- Bound `maxPlaintextBytes` and `maxChunkSizeLog2` when opening untrusted ciphertext; a hostile
header can otherwise declare a chunk size far larger than the reader intends to buffer.
- The chunk-size ceiling is symmetric: sealing and opening both default to `2^24`, so the
library never writes an object that a default reader would refuse. Exponents of 25 and 26
exist in the format but require `maxChunkSizeLog2` on the seal side _and_ the open side;
an object written above the default is unreadable to a caller that has not opted in.
- The per-object file identifier is drawn from the CSPRNG on every seal and is deliberately
not settable through `ChunkedSealOptions`. It is the only per-object input to the key
schedule, so reusing one under the same data key would repeat a chunk key and nonce
prefix — exposing the XOR of two plaintexts and leaking the header GMAC subkey. The
`@nestm/crypto/testing` seam that pins it exists solely to freeze format vectors and must
never be reachable from production code.
- Never persist a nonce, a nonce counter, or a file identifier and replay it. Nonces here are
either drawn fresh or derived from a fresh per-object identifier, which is what makes a
database rollback (point-in-time restore, replica promotion) unable to cause nonce reuse.
- `scrypt` parameters are bounded absolutely, not just per field: a derivation may not
reserve more than 1 GiB, and `maxmem` is capped independently of the caller. Combined with
the concurrency semaphore, this keeps a password-hardening parameter from becoming a
denial-of-service lever.

## Password and recovery-code boundary

`@nestm/crypto/password` derives key-encryption keys from user-supplied secrets.

- scrypt is memory-hard by design: each derivation holds roughly `128 * N * r` bytes, which is
64 MiB at the shipped defaults. `createPasswordKdf` bounds concurrent derivations (default 4) to
keep a burst of sign-ins from exhausting memory; raise the limit only against measured headroom.
- Parameters and salt are per-record and versioned. Re-derive and re-wrap on the next successful
authentication when policy strengthens; never reuse a salt across records.
- Always pass `info` to separate purposes. Without it, the same password yields the same key for
every use.
- Recovery secrets are full-entropy, so `deriveRecoveryKey` uses HKDF rather than a memory-hard KDF.
Store only the wrap the recovery key produces — persisting a verifier hash of the code creates an
offline oracle for anyone who reads the database.
- Recovery codes are display-once. The library never retains one, and `parseRecoveryCode` fails
closed on any prefix, character, length, canonicality, or checksum deviation.
- A derived key is a `KeyObject`. Node keeps that material outside the JavaScript heap, but it
cannot be wiped on demand; see the custody note below.

## Key custody

Unwrapped keys live only in process memory for as long as the application holds them.

- The library zeroes its own intermediate buffers, but a resident `KeyObject` is released to the
runtime, not erased. Anything that can read process memory — a core dump, a heap snapshot, an
attached debugger, `--inspect` — is equivalent to holding the keys.
- Disable core dumps and inspector ports in production, and keep key material out of logs, error
causes, and crash reporters.
- Keys held for a session should have an explicit lifetime and be dropped on sign-out, session
revocation, and password change. Replicating them to a shared cache re-introduces a decryptable
copy at rest and weakens the model that per-user key derivation is there to provide.

## Operational guidance

- Put stable, domain-specific data classification in `purpose`; never use request IDs or mutable labels.
Expand All @@ -149,6 +226,6 @@ would need to define update/upsert identity and migration behavior before offeri
bound a batch to 256 items and 10 MiB of aggregate plaintext/ciphertext.
- Monitor authentication failures and provider failures without logging values or native SDK payloads.
- Test key rotation and disaster recovery with representative ciphertext before retiring a key.
- Bound input sizes before accepting untrusted ciphertext; the library is buffered and not a streaming
encryption format.
- Bound input sizes before accepting untrusted ciphertext. The `nmc1` envelope is buffered and not a
streaming format; use `@nestm/crypto/stream` for payloads that must not be held in memory whole.
- Run credential-gated live tests only against disposable provider resources.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
"import": "./dist/keys/index.mjs",
"default": "./dist/keys/index.mjs"
},
"./password": {
"types": "./dist/password/index.d.mts",
"import": "./dist/password/index.mjs",
"default": "./dist/password/index.mjs"
},
"./stream": {
"types": "./dist/stream/index.d.mts",
"import": "./dist/stream/index.mjs",
"default": "./dist/stream/index.mjs"
},
"./key-wrap/rsa": {
"types": "./dist/key-wrap/rsa/index.d.mts",
"import": "./dist/key-wrap/rsa/index.mjs",
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const expectedExports = [
"./http",
"./prisma",
"./keys",
"./password",
"./stream",
"./key-wrap/rsa",
"./kms/aws",
"./kms/gcp",
Expand Down
Loading
Loading