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
|
||||
58
openspec/archive/2026-04-19-enterprise-crypto/proposal.md
Normal file
58
openspec/archive/2026-04-19-enterprise-crypto/proposal.md
Normal 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
|
||||
@@ -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
|
||||
23
openspec/archive/2026-04-19-enterprise-crypto/tasks.md
Normal file
23
openspec/archive/2026-04-19-enterprise-crypto/tasks.md
Normal 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/`
|
||||
Reference in New Issue
Block a user