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

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