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

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 main branch @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 .md docs

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 inline seededRng — 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_PARAMS
  • CRYPTO.* — 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.UNAVAILABLE
  • SODIUM.* — SODIUM.INIT.FAILED, SODIUM.KDF.INVALID_CONTEXT
  • BROWSER_CRYPTO.* — BROWSER_CRYPTO.KEY.GENERATION_FAILED, BROWSER_CRYPTO.NOT_AVAILABLE
  • CRYPTO_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: guards Math.random, Date.now, raw crypto.getRandomValues outside primitives/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