Files
suaveplan/openspec/archive/2026-04-19-enterprise-crypto/specs/crypto/spec.md
2026-05-27 23:29:53 +01:00

152 lines
6.3 KiB
Markdown

## 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