10 KiB
Design: enterprise-crypto-family
Context
The existing enterprise-crypto change planned a single @suaveplan/crypto package covering hashing, KDFs, AEAD, signing, and key agreement. After auditing the main branch implementation (all source files, not just exports) and the downstream packages that consume it, the design splits into six focused packages. All six ship at 0.1.0 on genesis. The noble libraries (@noble/hashes, @noble/ciphers, @noble/curves) remain the default implementation everywhere — zero WASM, audited, tree-shakeable, identical across Bun/Node/browser. The sodium and browser packages are opt-in overlays, not replacements.
A name collision was discovered: @suaveplan/crypto currently exports Rng = (n: number) => Uint8Array while @suaveplan/types exports Rng = { nextUint32(): number; nextFloat(): number }. This change renames crypto's type to ByteSource throughout.
Goals / Non-Goals
Goals:
- Ship six focused packages in dependency order:
hash→crypto→ (crypto-sodium∥crypto-browser) →crypto-keys→crypto-vault - Preserve every public name from the
mainbranch@suaveplan/crypto— nothing is removed, some things move packages - Engine-pluggable AEAD, Sign, Agree, SealedBox, AuthBox via interface + noble default in
@suaveplan/crypto; sodium/browser are opt-in engines - 100% coverage on all six packages (Layer 1/2 foundation rule)
- Full enterprise checklist: Zod schemas, structured errors, fast-check property tests, mitata benchmarks, OTEL instrumentation, co-located
.mddocs
Non-Goals:
- Streaming AEAD — waits on
@suaveplan/streams - Post-quantum (Kyber, Dilithium) — revisit when @noble ships stable implementations
- HSM / TPM / hardware-backed key storage
- JOSE / JWT / JWE — belongs in
@suaveplan/jwt-tenant - Replacing
@suaveplan/testing's inlineseededRng— the testing rule forbids adding workspace deps
Decisions
Package: @suaveplan/hash
Standalone hashing substrate, no engine concept needed — output is always deterministic for a given input.
src/
hash/
sha2.ts / sha2.test.ts / sha2.md # sha256, sha384, sha512, hash(algo, data)
mac/
hmac.ts / hmac.test.ts / hmac.md # hmac(algo, key, data)
kdf/
hkdf.ts / hkdf.test.ts / hkdf.md # hkdf(algo, ikm, { salt, info, length })
pbkdf2.ts / pbkdf2.test.ts / pbkdf2.md # pbkdf2(algo, pw, { salt, iterations, length })
argon2id.ts / argon2id.test.ts / argon2id.md # argon2id(pw, { salt, timeCost, memoryCost, parallelism, length })
encoding/
hex.ts / hex.test.ts / hex.md # toHex, fromHex
base64.ts / base64.test.ts / base64.md # toBase64, fromBase64, toBase64Url, fromBase64Url
errors/
codes.ts / hash-error.ts / index.ts
constants/index.ts
index.ts
HashAlgorithm = "SHA-256" | "SHA-384" | "SHA-512" — the dispatcher type stays here.
hasherFor(algo) is package-private (not exported), used internally by hmac/hkdf/pbkdf2.
PBKDF2 enforces OWASP 2023 minimum (600,000 iterations for SHA-256). Argon2id enforces RFC 9106 §4 minimums per-field with individual error context.
Package: @suaveplan/crypto
Engine interfaces + noble defaults + public façade functions + primitives.
src/
engines/
aead-engine.types.ts # AeadEngine interface
sign-engine.types.ts # SignEngine interface
agree-engine.types.ts # AgreeEngine interface
sealed-box-engine.types.ts # SealedBoxEngine interface
auth-box-engine.types.ts # AuthBoxEngine interface
aead/
aead.ts / aead.test.ts / aead.md # aeadEncrypt, aeadDecrypt, AeadAlgorithm (adds XChaCha20-Poly1305)
noble-aead-engine.ts # noble default AeadEngine impl
sign/
ed25519.ts / ed25519.test.ts / ed25519.md
ecdsa-p256.ts / ecdsa-p256.test.ts / ecdsa-p256.md
noble-sign-engine.ts
agree/
x25519.ts / x25519.test.ts / x25519.md
noble-agree-engine.ts
sealed-box/
sealed-box.ts / sealed-box.test.ts / sealed-box.md
noble-sealed-box-engine.ts
auth-box/
auth-box.ts / auth-box.test.ts / auth-box.md
noble-auth-box-engine.ts
primitives/
random-bytes.ts / random-bytes.test.ts / random-bytes.md # ByteSource, defaultByteSource, randomBytes
constant-time-equal.ts / ...
zeroize.ts / ...
errors/
codes.ts / crypto-error.ts / index.ts
constants/index.ts
index.ts
AeadAlgorithm widens to "AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305".
ByteSource = (n: number) => Uint8Array — replaces the old Rng type. All key-generation functions accept { byteSource?: ByteSource } (alias { rng?: ByteSource } for backwards compat).
Every public function accepts { engine?: <EngineType> } — omit to use the noble default.
Engine interfaces are exported as TypeScript types only — no runtime registration/factory needed. Consumers pass an engine instance.
Package: @suaveplan/crypto-sodium
Wraps libsodium-wrappers-sumo. Handles await sodium.ready via a lazy singleton that resolves once on first import.
src/
engines/
sodium-aead-engine.ts / ...
sodium-sign-engine.ts / ...
sodium-agree-engine.ts / ...
sodium-sealed-box-engine.ts / ...
sodium-auth-box-engine.ts / ...
primitives/
crypto-kdf.ts / ... # crypto_kdf_derive_from_key — not in any interface
crypto-auth.ts / ... # BLAKE2b MAC — not in any interface
crypto-pwhash.ts / ... # encoded password hash string — not in any interface
ristretto255.ts / ... # group ops — not in any interface
init.ts / init.md # sodiumReady(): Promise<void>
errors/ index.ts
index.ts
All exports are async-capable since sodium requires init. The engine implementations return Promise-wrapped results where needed, conforming to the async variant of each engine interface.
Package: @suaveplan/crypto-browser
Uses crypto.subtle. Browser and modern Node/Bun compatible (Web Crypto API).
src/
engines/
webcrypto-aead-engine.ts / ... # AES-256-GCM via crypto.subtle (hardware-accelerated)
webcrypto-sign-engine.ts / ... # Ed25519, ECDSA-P256 via crypto.subtle
webcrypto-agree-engine.ts / ... # X25519, ECDH-P256 via crypto.subtle
rsa/
rsa.ts / rsa.test.ts / rsa.md # RSA-PSS, RSA-OAEP, RSASSA-PKCS1
aes-kw/
aes-kw.ts / ... # AES Key Wrap
ecdsa-p384/
ecdsa-p384.ts / ...
ecdh-p384/
ecdh-p384.ts / ...
non-extractable-key/
non-extractable-key.ts / ... # opaque CryptoKey handle
errors/ index.ts
index.ts
All crypto.subtle operations are async — engine methods return Promise<Uint8Array>. Façade functions in @suaveplan/crypto already accept engine?; async engines are valid engine implementations.
Package: @suaveplan/crypto-keys
src/
keypair/
generate.ts / ... # generateKeypair(algo, { byteSource?, engine? })
validate.ts / ... # validateKeypair(keypair)
keychain/
keychain.ts / ... # in-memory keychain store
adapters/
memory-adapter.ts / ...
serialized-adapter.ts / ...
errors/ index.ts
index.ts
Engine-agnostic — delegates to @suaveplan/crypto façade functions.
Package: @suaveplan/crypto-vault
src/
mnemonic/
bip39.ts / ... # generateMnemonic, mnemonicToSeed (uses @scure/bip39)
vault/
vault.ts / ... # unlock(mnemonic, passphrase), lock(), sign(), derive()
vault-events.ts / ... # typed events: unlocked, locked, signed
derivation/
key-derivation.ts / ... # Argon2id/PBKDF2 unlock key from passphrase
errors/ index.ts
index.ts
Depends on @suaveplan/hash (KDFs), @suaveplan/crypto (signing façade), @suaveplan/crypto-keys, @suaveplan/events.
ByteSource vs Rng — the rename
@suaveplan/types's Rng = { nextUint32(): number; nextFloat(): number } is the canonical interface for number-producing randomness (used by @suaveplan/pseudo-random and @suaveplan/random).
@suaveplan/crypto's byte-producer type is renamed ByteSource = (n: number) => Uint8Array. Both the old name (Rng) and new name (ByteSource) are exported from @suaveplan/crypto during genesis (no consumer on genesis yet, so no migration cost — but the alias is there for when main consumers are ported).
Error shapes
Every package extends SuaveplanError with its own stable code namespace:
HASH.*—HASH.ALGORITHM.UNSUPPORTED,HASH.KDF.INVALID_PARAMSCRYPTO.*—CRYPTO.AEAD.DECRYPT_FAILED,CRYPTO.AEAD.INVALID_NONCE,CRYPTO.KEY.INVALID_LENGTH,CRYPTO.SIGN.VERIFY_FAILED,CRYPTO.SIGN.INVALID_KEY,CRYPTO.RNG.INVALID_LENGTH,CRYPTO.RNG.UNAVAILABLESODIUM.*—SODIUM.INIT.FAILED,SODIUM.KDF.INVALID_CONTEXTBROWSER_CRYPTO.*—BROWSER_CRYPTO.KEY.GENERATION_FAILED,BROWSER_CRYPTO.NOT_AVAILABLECRYPTO_KEYS.*,CRYPTO_VAULT.*
Error context never contains raw key bytes, plaintext, or authentication tags.
Coverage strategy
All six packages target 100% (Layer 1/2 foundation rule).
- Determinism: same inputs → same outputs
- Round-trip: encrypt→decrypt, sign→verify
- Rejection: invalid lengths, sub-minimum KDF params, bad nonce sizes
- Engine substitution: every public function tested with both noble default and a stub engine
- Property-based (fast-check): AEAD round-trip, sign/verify identity, constant-time-equal reflexivity
- Cross-cutting
tests/no-disallowed.test.ts: guardsMath.random,Date.now, rawcrypto.getRandomValuesoutsideprimitives/random-bytes.ts
Risks / Trade-offs
| Risk | Mitigation |
|---|---|
libsodium-wrappers-sumo WASM init is async |
Lazy singleton in init.ts; tests use await sodiumReady() in beforeAll |
crypto.subtle async engine breaks sync call sites |
Engine interfaces define both sync and async variants; noble default is sync; browser engine is async; façade returns Promise when engine is async |
@scure/bip39 adds a new catalog dep |
Adds to crypto catalog group in root package.json; acceptable |
| Six packages is a large wave | Implement in strict dependency order; each package's pre-publish gate must be green before the next starts |