Files
suaveplan/openspec/archive/2026-04-19-enterprise-crypto/design.md
2026-05-27 23:29:53 +01:00

134 lines
5.4 KiB
Markdown

# 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