5.4 KiB
Design: enterprise-crypto
Module layout
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:cryptovscrypto.subtlebranching - 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
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
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) === pfor 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
- AEAD:
- No
istanbul ignorepragmas tests/no-disallowed.test.tsguards againstDate.now/Math.random/crypto.getRandomValuesoutside the approvedrandom/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