# Design: enterprise-crypto ## Module layout ```text packages/universal/crypto/ ├── package.json # deps: @noble/*, @suaveplan/error, @suaveplan/types, tslib, zod ├── tsconfig.lib.json ├── vite.config.ts ├── vitest.config.ts # thresholds 100/100/100/100 ├── README.md ├── src/ │ ├── index.ts # barrel │ ├── constants/index.ts # standard lengths, default iterations │ ├── errors/ │ │ ├── codes.ts │ │ ├── crypto-error.ts │ │ ├── crypto-error.test.ts │ │ └── index.ts │ ├── random/ │ │ ├── random-bytes.ts # default RNG, the one place that reads crypto.getRandomValues │ │ ├── random-bytes.test.ts │ │ └── random-bytes.md │ ├── compare/ │ │ ├── constant-time-equal.ts │ │ ├── constant-time-equal.test.ts │ │ └── constant-time-equal.md │ ├── zeroize/ │ │ ├── zeroize.ts │ │ ├── zeroize.test.ts │ │ └── zeroize.md │ ├── hash/ │ │ ├── sha2.ts # sha256, sha384, sha512 via @noble/hashes │ │ ├── sha2.test.ts │ │ └── sha2.md │ ├── hmac/ │ │ ├── hmac.ts │ │ ├── hmac.test.ts │ │ └── hmac.md │ ├── kdf/ │ │ ├── hkdf.ts + hkdf.test.ts + hkdf.md │ │ ├── pbkdf2.ts + pbkdf2.test.ts + pbkdf2.md │ │ └── argon2id.ts + argon2id.test.ts + argon2id.md │ ├── aead/ │ │ ├── aead.ts # aeadEncrypt / aeadDecrypt dispatcher │ │ ├── aead.test.ts │ │ └── aead.md │ ├── sign/ │ │ ├── ed25519.ts + ed25519.test.ts + ed25519.md │ │ ├── ecdsa-p256.ts + ecdsa-p256.test.ts + ecdsa-p256.md │ ├── agree/ │ │ └── x25519.ts + x25519.test.ts + x25519.md │ └── __benchmarks__/ │ └── crypto.bench.ts ├── tests/ │ ├── setup/polyfills.ts │ ├── dep-graph.test.ts │ ├── no-disallowed.test.ts │ └── tree-shake.test.ts └── CHANGELOG.md ``` ## Key design decisions ### Why @noble/* as the implementation - Pure TypeScript — zero WASM, zero native builds, zero platform-specific branches - Audited (Trail of Bits, Cure53) - Used in production by MetaMask, Ethereum ecosystem, etc. - Identical behaviour across Bun, Node, and browser — no `node:crypto` vs `crypto.subtle` branching - Small surface, tree-shakes well The alternative (capability-detecting between `node:crypto` and `crypto.subtle`) adds branching complexity that doesn't buy anything: noble is fast enough for the hot paths, and for the slow paths (Argon2id) every implementation is equally slow. ### AEAD dispatcher ```ts type AeadAlgorithm = "AES-256-GCM" | "ChaCha20-Poly1305"; export function aeadEncrypt( algo: AeadAlgorithm, key: Uint8Array, plaintext: Uint8Array, options: { nonce: Uint8Array; aad?: Uint8Array } ): Uint8Array; ``` One function, algorithm selected by string — consumers don't need to import the specific algorithm module. Nonce is caller-provided (callers must not reuse nonces). ### Key + nonce validation Every public function validates its inputs at the boundary via Zod-adjacent checks (not Zod itself — these are hot paths, we want branch-and-throw not schema-parse). Wrong key length → `CryptoError("CRYPTO.KEY.INVALID_LENGTH")`. Wrong nonce length → `CRYPTO.AEAD.INVALID_NONCE`. Etc. ### Random source injection ```ts export interface RandomDeps { readonly rng?: (n: number) => Uint8Array; } export function randomBytes(n: number, deps: RandomDeps = {}): Uint8Array { const source = deps.rng ?? defaultRandomBytes; return source(n); } ``` `defaultRandomBytes` lives in `random/random-bytes.ts` and is the ONLY place that calls `crypto.getRandomValues`. Every other module (`ed25519.generateKeypair`, etc.) accepts `{ rng? }` and forwards. ### Error messages Messages are i18n keys — `"error.crypto.aead.decrypt-failed"`, not human strings. No dynamic content in messages that could include secret material. Numerical facts (lengths, algorithm names) are allowed in `context`; raw bytes are never put in `context`. ### Zeroing `zeroize(buf)` fills the array with zero via `buf.fill(0)`. It's not possible to guarantee zeroing in JS (GC may have already copied), but calling it communicates intent and minimises the window. Tests verify `zeroize(x); expect(x.every(b => b === 0)).toBe(true)`. ## Coverage strategy - Enumeration tests for every branch (valid, boundary, rejected input) - Property-based tests via fast-check: - AEAD: `decrypt(encrypt(p, k, n, aad), k, n, aad) === p` for arbitrary p, n, aad - Sign/verify: `verify(pk, sign(sk, m), m) === true`; wrong message → false - Hash: determinism - Compare: `constantTimeEqual(a, a)` always true; `a !== b` → false - No `istanbul ignore` pragmas - `tests/no-disallowed.test.ts` guards against `Date.now` / `Math.random` / `crypto.getRandomValues` outside the approved `random/random-bytes.ts` ## Non-goals - Streaming AEAD — waits on `@suaveplan/streams` - Hardware key storage, HSM, TPM - Post-quantum (Kyber, Dilithium) — revisit once NIST standards stabilise and @noble ships them - JOSE / JWT / JWE — belongs in `@suaveplan/jwt-tenant`, not here