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