Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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

View File

@@ -0,0 +1,58 @@
# Change: enterprise-crypto
## Why
`@suaveplan/crypto` is the single surface every other package uses for hashing, HMAC, HKDF/PBKDF2/Argon2id key derivation, AEAD symmetric encryption, and public-key signing. Having one canonical implementation with identical semantics across Bun, Node, and browser means consumers never pick between `node:crypto` and `crypto.subtle` — they import from one place, get structured errors, constant-time comparisons, buffer zeroing, and injectable RNG/clock for tests.
Per the foundation-100-coverage rule, it ships at 100% coverage.
## What Changes
### New package: `packages/universal/crypto`
Pure-TypeScript cryptographic substrate. Runtime dependencies: `@noble/ciphers`, `@noble/curves`, `@noble/hashes` (audited pure-TS crypto libraries), plus `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
#### Primitives
- **`randomBytes(n, { rng? })`** — cryptographic random bytes; default uses `crypto.getRandomValues`
- **`constantTimeEqual(a, b)`** — constant-time byte comparison
- **`zeroize(buf)`** — overwrite a `Uint8Array` with zeros
#### Hashes
- **`sha256(data)`**, **`sha384(data)`**, **`sha512(data)`** — one-shot digests returning `Uint8Array`
#### Keyed MACs + KDFs
- **`hmac(algo, key, data)`** — HMAC-SHA-256/384/512
- **`hkdf(algo, ikm, { salt, info, length })`** — HKDF extract-and-expand
- **`pbkdf2(algo, password, { salt, iterations, length })`** — PBKDF2
- **`argon2id(password, { salt, timeCost, memoryCost, parallelism, length })`** — Argon2id (memory-hard)
#### AEAD
- **`aeadEncrypt(algo, key, plaintext, { nonce, aad? })`** — AES-256-GCM and ChaCha20-Poly1305
- **`aeadDecrypt(algo, key, ciphertext, { nonce, aad? })`** — paired decrypter; throws on tag mismatch
#### Signing + key agreement
- **`ed25519.generateKeypair({ rng? })`**, **`ed25519.sign(secret, message)`**, **`ed25519.verify(public, signature, message)`**
- **`x25519.generateKeypair({ rng? })`**, **`x25519.agree(mySecret, theirPublic)`** — ECDH shared-secret
- **`ecdsaP256.generateKeypair({ rng? })`**, **`ecdsaP256.sign(secret, message)`**, **`ecdsaP256.verify(public, signature, message)`**
#### Error surface
- **`CryptoError`** extends `SuaveplanError<CryptoCode>` with codes like `CRYPTO.AEAD.DECRYPT_FAILED`, `CRYPTO.KEY.INVALID_LENGTH`, `CRYPTO.SIGN.VERIFY_FAILED`, `CRYPTO.KDF.INVALID_PARAMS`, `CRYPTO.RNG.UNAVAILABLE`
- Errors never leak plaintext, key bytes, or tag values in messages/context
#### Determinism
- All randomness flows through an injectable RNG param (`{ rng?: Rng }`) — the default is a thin wrapper over `crypto.getRandomValues`, located in exactly one file so tests can swap it
- No module-scope `Math.random()` / `Date.now()` — guarded by `tests/no-disallowed.test.ts`
## Impact
- Unblocks every downstream package that needs crypto (auth, jwt-tenant, keys, network-encryption, surrealdb keys, etc.)
- Depends on `@suaveplan/error`, `@suaveplan/types`, `@noble/ciphers`, `@noble/curves`, `@noble/hashes`
- One new catalog group (`crypto`) in the root `package.json`
- Non-goal for this change: streaming AEAD (needs `@suaveplan/streams` first), hardware-backed key storage, post-quantum algorithms, HSM integration

View File

@@ -0,0 +1,151 @@
## ADDED Requirements
### Requirement: Single canonical implementation
The package SHALL implement every primitive on top of `@noble/ciphers`, `@noble/curves`, and `@noble/hashes` — NOT on top of `node:crypto` or `crypto.subtle`. Behaviour MUST be identical across Bun, Node, and modern browsers.
#### Scenario: Same output across runtimes
- **WHEN** `sha256(x)` runs under Bun AND under a browser with the same input
- **THEN** the returned bytes are equal
### Requirement: Constant-time comparison
The package SHALL export `constantTimeEqual(a, b)` that returns `true` iff `a` and `b` have the same length AND every byte is equal. Execution time MUST be independent of the position of the first differing byte.
#### Scenario: Equal buffers
- **WHEN** `constantTimeEqual(a, b)` is called with byte-equal inputs
- **THEN** it returns `true`
#### Scenario: Different length rejected in constant time
- **WHEN** `constantTimeEqual(a, b)` is called with unequal-length inputs
- **THEN** it returns `false` without branching on content
### Requirement: Zeroization
The package SHALL export `zeroize(buf)` that fills `buf` with zero bytes.
#### Scenario: In-place zero
- **WHEN** `zeroize(x)` is called on a non-empty `Uint8Array`
- **THEN** every element of `x` is `0` afterwards
### Requirement: Hash surface
The package SHALL export `sha256`, `sha384`, `sha512` one-shot digest functions each returning a `Uint8Array` of the correct length (32, 48, 64 bytes respectively).
#### Scenario: Round-trip determinism
- **WHEN** `sha256(x)` is called twice with identical input
- **THEN** both calls return byte-identical output
### Requirement: HMAC, HKDF, PBKDF2, Argon2id
The package SHALL export `hmac`, `hkdf`, `pbkdf2`, `argon2id` with the algorithm selected by a string parameter for HMAC/HKDF/PBKDF2 (`"SHA-256" | "SHA-384" | "SHA-512"`). Argon2id is parameterised by `timeCost`, `memoryCost` (KiB), `parallelism`, and `length`.
#### Scenario: HMAC determinism
- **WHEN** `hmac("SHA-256", key, data)` is called twice with identical inputs
- **THEN** both calls return byte-identical output
#### Scenario: HKDF output length honoured
- **WHEN** `hkdf("SHA-256", ikm, { salt, info, length: 42 })` is called
- **THEN** the returned array has byte length 42
#### Scenario: PBKDF2 iterations below floor rejected
- **WHEN** `pbkdf2(algo, password, { iterations: 999, ... })` is called
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")`
#### Scenario: Argon2id parameter validation
- **WHEN** `argon2id(password, { memoryCost: 512, ... })` is called with `memoryCost < 8`
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")` (Argon2id rfc 9106 minimum)
### Requirement: AEAD (AES-256-GCM, ChaCha20-Poly1305)
The package SHALL export `aeadEncrypt` and `aeadDecrypt` dispatching by algorithm string. The key/nonce lengths SHALL be validated at the boundary. Decryption with a wrong tag SHALL throw `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` with no plaintext leakage.
#### Scenario: AES-256-GCM round-trip
- **WHEN** `aeadDecrypt("AES-256-GCM", key, aeadEncrypt("AES-256-GCM", key, p, { nonce, aad }), { nonce, aad })` is called
- **THEN** the returned bytes equal `p`
#### Scenario: ChaCha20-Poly1305 round-trip
- **WHEN** the same round-trip is performed with `"ChaCha20-Poly1305"`
- **THEN** the returned bytes equal the plaintext
#### Scenario: Tampered ciphertext rejected
- **WHEN** `aeadDecrypt` is called with a ciphertext whose tag was flipped
- **THEN** it throws `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` AND does not return any bytes
#### Scenario: Wrong key length rejected
- **WHEN** `aeadEncrypt("AES-256-GCM", key, p, { nonce })` is called with `key.length !== 32`
- **THEN** it throws `CryptoError("CRYPTO.KEY.INVALID_LENGTH")`
#### Scenario: Wrong nonce length rejected
- **WHEN** AES-256-GCM nonce length is not 12 OR ChaCha20-Poly1305 nonce length is not 12
- **THEN** `aeadEncrypt` throws `CryptoError("CRYPTO.AEAD.INVALID_NONCE")`
### Requirement: Ed25519, X25519, ECDSA-P256
The package SHALL export namespaces `ed25519`, `x25519`, `ecdsaP256` with `generateKeypair`, `sign`/`agree`, and `verify` where applicable.
#### Scenario: Ed25519 sign/verify round-trip
- **WHEN** `ed25519.verify(pk, ed25519.sign(sk, m), m)` is called
- **THEN** it returns `true`
#### Scenario: Ed25519 mismatched message
- **WHEN** `ed25519.verify(pk, signatureOfM, otherMessage)` is called
- **THEN** it returns `false`
#### Scenario: X25519 shared secret symmetry
- **WHEN** both parties compute `x25519.agree(mySecret, theirPublic)`
- **THEN** they arrive at the same shared secret
#### Scenario: ECDSA-P256 sign/verify round-trip
- **WHEN** `ecdsaP256.verify(pk, ecdsaP256.sign(sk, m), m)` is called
- **THEN** it returns `true`
### Requirement: Deterministic RNG injection
Every function that consumes randomness SHALL accept `{ rng? }` to override the default. The default randomness source lives in exactly one file (`src/random/random-bytes.ts`) and is the only place that reads `crypto.getRandomValues`.
#### Scenario: Injected RNG used
- **WHEN** `randomBytes(16, { rng: (n) => new Uint8Array(n).fill(7) })` is called
- **THEN** it returns a 16-byte array of 0x07s
#### Scenario: No module-scope non-determinism
- **WHEN** `src/` is scanned for `crypto.getRandomValues`, `Math.random`, `Date.now`
- **THEN** those symbols appear only inside `src/random/random-bytes.ts`
### Requirement: Structured errors never leak secrets
All thrown errors SHALL be `CryptoError` subclasses with codes prefixed `CRYPTO.`. Error messages and `context` MUST NOT contain plaintext, key bytes, signatures, or authentication tags. Lengths, algorithm names, and boolean facts are allowed.
#### Scenario: Decryption failure message is generic
- **WHEN** `aeadDecrypt` fails
- **THEN** the thrown `CryptoError`'s `context` contains at most `{ algorithm, ciphertextLength }` — never raw bytes
### Requirement: 100% test coverage
`vitest.config.ts` MUST enforce `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100`. Property-based tests (fast-check) cover: AEAD round-trip, Ed25519 sign/verify, X25519 symmetry, ECDSA-P256 round-trip, hash determinism, constant-time-equal invariants.
#### Scenario: Coverage threshold enforced
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/crypto` runs
- **THEN** it exits non-zero if any metric drops below 100

View File

@@ -0,0 +1,23 @@
> Archived 2026-04-19: all tasks retroactively ticked; packages shipped under universal/crypto/.
# Tasks: enterprise-crypto
- [x] 1. Add `crypto` catalog (@noble/ciphers, @noble/curves, @noble/hashes) to root package.json
- [x] 2. Scaffold package.json, tsconfig, vite/vitest configs (100 thresholds)
- [x] 3. errors: `CryptoError` extending `SuaveplanError` with `CRYPTO_CODES`
- [x] 4. random: `randomBytes` with injectable RNG; single module-level `crypto.getRandomValues` callsite
- [x] 5. compare: `constantTimeEqual`
- [x] 6. zeroize: `zeroize`
- [x] 7. hash: `sha256`/`sha384`/`sha512`
- [x] 8. hmac: `hmac` with algorithm dispatch
- [x] 9. kdf: `hkdf`, `pbkdf2`, `argon2id` with parameter validation
- [x] 10. aead: `aeadEncrypt`/`aeadDecrypt` dispatching AES-256-GCM + ChaCha20-Poly1305
- [x] 11. sign: `ed25519`, `ecdsaP256` namespaces
- [x] 12. agree: `x25519` namespace
- [x] 13. Cross-cutting `tests/`: dep-graph, no-disallowed, tree-shake
- [x] 14. Property-based tests via fast-check for every primitive
- [x] 15. Co-located `.md` per implementation file
- [x] 16. README
- [x] 17. `bunx turbo lint typecheck build test:coverage --filter=@suaveplan/crypto --force` green at 100/100/100/100
- [x] 18. Publish to Verdaccio at 0.0.0
- [x] 19. Archive change folder to `openspec/archive/<yyyy-mm-dd>-enterprise-crypto/`