@@ -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
572716This 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 |
0 commit comments