Initial Commit
This commit is contained in:
220
openspec/archive/2026-04-18-enterprise-crypto-family/design.md
Normal file
220
openspec/archive/2026-04-18-enterprise-crypto-family/design.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# 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.
|
||||
|
||||
```text
|
||||
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.
|
||||
|
||||
```text
|
||||
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.
|
||||
|
||||
```text
|
||||
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).
|
||||
|
||||
```text
|
||||
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`
|
||||
|
||||
```text
|
||||
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`
|
||||
|
||||
```text
|
||||
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 |
|
||||
Reference in New Issue
Block a user