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

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: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

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) === 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