6.3 KiB
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
falsewithout 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-emptyUint8Array - THEN every element of
xis0afterwards
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 withmemoryCost < 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
aeadDecryptis 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 withkey.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
aeadEncryptthrowsCryptoError("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 forcrypto.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
aeadDecryptfails - THEN the thrown
CryptoError'scontextcontains 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/cryptoruns - THEN it exits non-zero if any metric drops below 100