152 lines
6.3 KiB
Markdown
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
|