Skip to content

Commit 5d1f9fa

Browse files
authored
feat: zero-access key-hierarchy primitives (password KDF, key-wrap records, nmcs1 stream) (#12)
Add three capabilities that the artifact-studio zero-access encryption program builds on: - @nestm/crypto/password: registry-based scrypt KDF with absolute parameter ceilings and a hard maxmem cap, canonical versioned params codec, and recovery codes (Crockford base32, checksum-padded groups). - @nestm/crypto/keys: KeyWrapRecord (A256GCMKW secret wraps + X25519 sealed boxes) with recipient-identity-bound AAD and length-checked unwrap, plus hmacSha256 / timingSafeEqualBytes. - @nestm/crypto/stream: the nmcs1 chunked AEAD container (fixed 512B header, per-chunk HKDF-derived keys/nonces from a per-object random fileId, truncation/ reorder/splice/header-tamper resistant, buffered + Web-stream APIs). fileId is a testing-only seam, never a public option, so nonce reuse cannot be caused by a caller. Also export isCipherEnvelope from ./core. No new runtime deps; ESM-only.
1 parent c08d82e commit 5d1f9fa

26 files changed

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

README.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,150 @@ function createTenantPrismaFieldEncryption(
567567
): TenantPrismaWriteProcessor;
568568
```
569569

570+
## Key hierarchies
571+
572+
`@nestm/crypto/keys`, `@nestm/crypto/password`, and `@nestm/crypto/stream` are framework-neutral
573+
building blocks for key hierarchies where the server holds no long-lived master key: a password
574+
unlocks a user key, user keys hold grants to shared keys, and shared keys protect per-object data
575+
keys. They are independent of `CipherEngine` and `DataKeyProvider`.
576+
577+
### Asymmetric keys and sealed boxes — `@nestm/crypto/keys`
578+
579+
```ts
580+
import { generateX25519KeyPair, sealTo, openKeyFrom, hkdfSha256 } from "@nestm/crypto/keys";
581+
582+
const recipient = generateX25519KeyPair();
583+
// Sealing needs only the public key, so a writer never needs the recipient online.
584+
const sealed = sealTo(recipient.publicKey, dataKey, { info: "wsk.seal", aad: "epoch:1" });
585+
const recovered = openKeyFrom(recipient.privateKey, sealed, { info: "wsk.seal", aad: "epoch:1" });
586+
```
587+
588+
`sealTo` uses ephemeral-static X25519 → HKDF-SHA256 → AES-256-GCM, binds the recipient public key
589+
into the key schedule, derives the nonce (never transmitting it), and caps payloads at 64 KiB — it
590+
is a key-wrapping primitive, not a data cipher.
591+
592+
### Key-wrap records
593+
594+
A single versioned record covers both wrapping flavours, so a grant table or a file header can
595+
carry a heterogeneous list of them:
596+
597+
```ts
598+
import {
599+
wrapKeyWithSecret,
600+
wrapKeyToRecipient,
601+
encodeKeyWrapRecords,
602+
decodeKeyWrapRecords,
603+
selectKeyWrapRecord,
604+
unwrapKeyFromRecipient,
605+
} from "@nestm/crypto/keys";
606+
607+
const grants = [
608+
wrapKeyToRecipient(alicePublicKey, workspaceKey, { recipientId: "user:alice" }),
609+
wrapKeyWithSecret(orgKek, workspaceKey, { recipientId: "org:acme" }),
610+
];
611+
const stored = encodeKeyWrapRecords(grants);
612+
613+
const mine = selectKeyWrapRecord(decodeKeyWrapRecords(stored), {
614+
recipientType: "x25519",
615+
recipientId: "user:alice",
616+
});
617+
const workspaceKeyAgain = unwrapKeyFromRecipient(alicePrivateKey, mine!);
618+
```
619+
620+
The algorithm, recipient type, and recipient identifier are authenticated, so a record cannot be
621+
retargeted at a different recipient even under the same wrapping key.
622+
623+
### Password-derived keys — `@nestm/crypto/password`
624+
625+
```ts
626+
import {
627+
createPasswordKdf,
628+
generatePasswordSalt,
629+
encodePasswordKdfParams,
630+
PASSWORD_KDF_SCRYPT_DEFAULT,
631+
} from "@nestm/crypto/password";
632+
633+
const kdf = createPasswordKdf(); // scrypt N=2^16, r=8, p=1; at most 4 concurrent derivations
634+
const salt = generatePasswordSalt();
635+
const kek = await kdf.derive({
636+
password,
637+
salt,
638+
kdf: PASSWORD_KDF_SCRYPT_DEFAULT,
639+
info: "umk.password", // domain separation; the same password yields unrelated keys per purpose
640+
});
641+
// Persist the salt and encodePasswordKdfParams(PASSWORD_KDF_SCRYPT_DEFAULT) beside the wrap.
642+
```
643+
644+
Parameters are versioned per record, so a stored key can be re-wrapped under stronger parameters
645+
later without a format change. Registering an Argon2id implementation of `PasswordKdfAlgorithm`
646+
requires no format change either; the default stays dependency-free.
647+
648+
Recovery codes are full-entropy secrets, so they derive through HKDF rather than a memory-hard KDF:
649+
650+
```ts
651+
import { generateRecoveryCode, parseRecoveryCode, deriveRecoveryKey } from "@nestm/crypto/password";
652+
653+
// ASR1-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX — display once, never store the code itself
654+
const { code, secret } = generateRecoveryCode();
655+
const recoveryKek = deriveRecoveryKey(secret, recoverySalt, "umk.recovery");
656+
// later
657+
const recoveryKekAgain = deriveRecoveryKey(
658+
parseRecoveryCode(userInput),
659+
recoverySalt,
660+
"umk.recovery",
661+
);
662+
```
663+
664+
Store only the wrap the code produces. Persisting a verifier hash would hand an offline oracle to
665+
anyone who reads the database.
666+
667+
### Chunked files — `@nestm/crypto/stream`
668+
669+
`nmcs1` is a self-contained container: a fixed 512-byte authenticated header followed by
670+
AES-256-GCM chunks (1 MiB by default, 20 bytes of framing overhead each).
671+
672+
```ts
673+
import { sealChunked, openChunked, inspectChunked } from "@nestm/crypto/stream";
674+
675+
const sealed = sealChunked(dataKey, plaintext, {
676+
keyReference: "ws:1f9e:e1",
677+
aad: "org:acme|ws:1f9e", // binds the object to its location; a relocated copy fails to open
678+
wrapRecords: [wrapKeyToRecipient(workspacePublicKey, dataKey, { recipientId: "ws:1f9e" })],
679+
});
680+
681+
const header = inspectChunked(sealed); // framing metadata only — nothing is authenticated yet
682+
const plaintextAgain = openChunked(dataKey, sealed, { aad: "org:acme|ws:1f9e" });
683+
```
684+
685+
Large payloads use the Web-Streams form, which is byte-identical to the buffered form when
686+
`plaintextLength` is declared (`sealChunked` always knows the length; a stream does not, so an
687+
undeclared stream records the length as absent):
688+
689+
```ts
690+
import { createChunkedSealStream, createChunkedOpenStream } from "@nestm/crypto/stream";
691+
692+
await source.pipeThrough(createChunkedSealStream(dataKey, { keyReference })).pipeTo(destination);
693+
await ciphertext.pipeThrough(createChunkedOpenStream(dataKey)).pipeTo(sink);
694+
```
695+
696+
`createChunkedOpenStream` emits an **authenticated prefix**: every chunk is verified before it is
697+
emitted, but truncation is only detected at end of stream. Consumers that must not act on a partial
698+
payload should use `openChunked`.
699+
700+
Because the header is fixed-size and every chunk but the last is full, sizes convert exactly in both
701+
directions without reading the object — useful for reporting plaintext sizes from a storage listing:
702+
703+
```ts
704+
import { chunkedCiphertextLength, chunkedPlaintextLength } from "@nestm/crypto/stream";
705+
706+
chunkedCiphertextLength(1_234); // 1_766
707+
chunkedPlaintextLength(1_766); // 1_234 (undefined when the size is not one the format can produce)
708+
```
709+
710+
Nonces are derived per chunk from the data key, a per-object random file identifier, and a hash of
711+
the header; no counter is persisted anywhere, so restoring a database to an earlier point in time
712+
cannot cause nonce reuse.
713+
570714
## `nestjs-field-encryption` compatibility map
571715

572716
This package now covers the integration surfaces demonstrated by
@@ -595,6 +739,9 @@ plaintext with `@nestm/crypto`; relabeling or importing the old ciphertext is no
595739
| Entry point | Purpose |
596740
| ---------------------------- | ----------------------------------------------------------------------------- |
597741
| `@nestm/crypto/core` | AES-256-GCM engine, envelope codec, local AES KEK ring, contracts, and errors |
742+
| `@nestm/crypto/keys` | X25519 keys, HKDF-SHA256, sealed boxes, key-wrap records, HMAC helpers |
743+
| `@nestm/crypto/password` | password-derived key-encryption keys and display-once recovery codes |
744+
| `@nestm/crypto/stream` | `nmcs1` chunked AES-256-GCM container for buffered and streaming payloads |
598745
| `@nestm/crypto/fields` | purpose-decorated class traversal |
599746
| `@nestm/crypto/tenant` | tenant-bound cipher and field services |
600747
| `@nestm/crypto/http` | request-encryption pipe and opt-in response-decryption interceptor |

SECURITY.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,83 @@ different row that uses the same purpose in the same tenant. Applications that r
141141
must use an explicit service boundary with stable row-specific AAD; a future Prisma AAD-resolver API
142142
would need to define update/upsert identity and migration behavior before offering that guarantee.
143143

144+
## Chunked stream boundary
145+
146+
`@nestm/crypto/stream` (`nmcs1`) protects whole objects, buffered or streamed.
147+
148+
- The 512-byte header is authenticated by its own tag and hashed into the chunk key schedule, so a
149+
change to the key reference, chunk size, context associated data, or inline wrap records makes
150+
every chunk fail authentication. Header padding must be zero and reserved flag bits must be clear.
151+
- Each chunk's nonce is `noncePrefix ‖ chunkIndex ‖ finalFlag`, where the prefix derives from the
152+
data key and a per-object random 16-byte file identifier. **No nonce input is ever persisted or
153+
read back from storage.** A counter written to a database and later restored to an earlier point
154+
in time — by point-in-time recovery, a replica promotion, or a restored backup — would reissue
155+
nonces under the same key and break AES-GCM catastrophically. Any future change to this format
156+
must preserve that property: nonces come from a CSPRNG draw or from an HKDF over per-object
157+
random material, never from durable mutable state.
158+
- The final chunk is flagged in both its nonce and its associated data, so truncation, chunk
159+
reordering, and splicing chunks between objects all fail authentication rather than yielding a
160+
short or wrong plaintext.
161+
- `createChunkedOpenStream` emits an **authenticated prefix**. Each chunk is verified before it is
162+
emitted, but truncation is only detectable at end of stream, so a consumer acting on partial
163+
output may act on a prefix of the plaintext. Use `openChunked` where all-or-nothing semantics are
164+
required, and treat a stream that errors mid-flight as having produced nothing.
165+
- `inspectChunked` reports framing only and marks itself `authenticated: false`. Never make a trust
166+
decision on its output.
167+
- The context associated data in the header is **not encrypted**. Bind location and ownership there
168+
(organization, workspace, object key); never put secrets in it.
169+
- Bound `maxPlaintextBytes` and `maxChunkSizeLog2` when opening untrusted ciphertext; a hostile
170+
header can otherwise declare a chunk size far larger than the reader intends to buffer.
171+
- The chunk-size ceiling is symmetric: sealing and opening both default to `2^24`, so the
172+
library never writes an object that a default reader would refuse. Exponents of 25 and 26
173+
exist in the format but require `maxChunkSizeLog2` on the seal side _and_ the open side;
174+
an object written above the default is unreadable to a caller that has not opted in.
175+
- The per-object file identifier is drawn from the CSPRNG on every seal and is deliberately
176+
not settable through `ChunkedSealOptions`. It is the only per-object input to the key
177+
schedule, so reusing one under the same data key would repeat a chunk key and nonce
178+
prefix — exposing the XOR of two plaintexts and leaking the header GMAC subkey. The
179+
`@nestm/crypto/testing` seam that pins it exists solely to freeze format vectors and must
180+
never be reachable from production code.
181+
- Never persist a nonce, a nonce counter, or a file identifier and replay it. Nonces here are
182+
either drawn fresh or derived from a fresh per-object identifier, which is what makes a
183+
database rollback (point-in-time restore, replica promotion) unable to cause nonce reuse.
184+
- `scrypt` parameters are bounded absolutely, not just per field: a derivation may not
185+
reserve more than 1 GiB, and `maxmem` is capped independently of the caller. Combined with
186+
the concurrency semaphore, this keeps a password-hardening parameter from becoming a
187+
denial-of-service lever.
188+
189+
## Password and recovery-code boundary
190+
191+
`@nestm/crypto/password` derives key-encryption keys from user-supplied secrets.
192+
193+
- scrypt is memory-hard by design: each derivation holds roughly `128 * N * r` bytes, which is
194+
64 MiB at the shipped defaults. `createPasswordKdf` bounds concurrent derivations (default 4) to
195+
keep a burst of sign-ins from exhausting memory; raise the limit only against measured headroom.
196+
- Parameters and salt are per-record and versioned. Re-derive and re-wrap on the next successful
197+
authentication when policy strengthens; never reuse a salt across records.
198+
- Always pass `info` to separate purposes. Without it, the same password yields the same key for
199+
every use.
200+
- Recovery secrets are full-entropy, so `deriveRecoveryKey` uses HKDF rather than a memory-hard KDF.
201+
Store only the wrap the recovery key produces — persisting a verifier hash of the code creates an
202+
offline oracle for anyone who reads the database.
203+
- Recovery codes are display-once. The library never retains one, and `parseRecoveryCode` fails
204+
closed on any prefix, character, length, canonicality, or checksum deviation.
205+
- A derived key is a `KeyObject`. Node keeps that material outside the JavaScript heap, but it
206+
cannot be wiped on demand; see the custody note below.
207+
208+
## Key custody
209+
210+
Unwrapped keys live only in process memory for as long as the application holds them.
211+
212+
- The library zeroes its own intermediate buffers, but a resident `KeyObject` is released to the
213+
runtime, not erased. Anything that can read process memory — a core dump, a heap snapshot, an
214+
attached debugger, `--inspect` — is equivalent to holding the keys.
215+
- Disable core dumps and inspector ports in production, and keep key material out of logs, error
216+
causes, and crash reporters.
217+
- Keys held for a session should have an explicit lifetime and be dropped on sign-out, session
218+
revocation, and password change. Replicating them to a shared cache re-introduces a decryptable
219+
copy at rest and weakens the model that per-user key derivation is there to provide.
220+
144221
## Operational guidance
145222

146223
- Put stable, domain-specific data classification in `purpose`; never use request IDs or mutable labels.
@@ -149,6 +226,6 @@ would need to define update/upsert identity and migration behavior before offeri
149226
bound a batch to 256 items and 10 MiB of aggregate plaintext/ciphertext.
150227
- Monitor authentication failures and provider failures without logging values or native SDK payloads.
151228
- Test key rotation and disaster recovery with representative ciphertext before retiring a key.
152-
- Bound input sizes before accepting untrusted ciphertext; the library is buffered and not a streaming
153-
encryption format.
229+
- Bound input sizes before accepting untrusted ciphertext. The `nmc1` envelope is buffered and not a
230+
streaming format; use `@nestm/crypto/stream` for payloads that must not be held in memory whole.
154231
- Run credential-gated live tests only against disposable provider resources.

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@
7070
"import": "./dist/keys/index.mjs",
7171
"default": "./dist/keys/index.mjs"
7272
},
73+
"./password": {
74+
"types": "./dist/password/index.d.mts",
75+
"import": "./dist/password/index.mjs",
76+
"default": "./dist/password/index.mjs"
77+
},
78+
"./stream": {
79+
"types": "./dist/stream/index.d.mts",
80+
"import": "./dist/stream/index.mjs",
81+
"default": "./dist/stream/index.mjs"
82+
},
7383
"./key-wrap/rsa": {
7484
"types": "./dist/key-wrap/rsa/index.d.mts",
7585
"import": "./dist/key-wrap/rsa/index.mjs",

scripts/check-package.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const expectedExports = [
1212
"./http",
1313
"./prisma",
1414
"./keys",
15+
"./password",
16+
"./stream",
1517
"./key-wrap/rsa",
1618
"./kms/aws",
1719
"./kms/gcp",

0 commit comments

Comments
 (0)