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

14 KiB

crypto Specification

Purpose

@suaveplan/crypto is the multi-algorithm cryptographic façade for the monorepo. It exposes a stable, engine-pluggable surface for AEAD encryption (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305), digital signatures (Ed25519, ECDSA-P256), key agreement (X25519), anonymous-sender sealed boxes, authenticated-sender boxes, constant-time byte comparison, in-place zeroization, and a single-callsite CSPRNG. Per-primitive implementations (hash, curve, cipher) live in dedicated subpackages (@suaveplan/crypto-hash, @suaveplan/crypto-curve, @suaveplan/crypto-cipher); this façade composes them and adds higher-level constructions.

Requirements

Requirement: Engine pluggability via dependency injection

Every public function that performs a cryptographic operation SHALL accept an optional { engine } option. When omitted, a default Noble-backed engine SHALL be used. When provided, the call SHALL be delegated to the supplied engine without falling back to the default.

Scenario: Default engine used when no engine option supplied

  • WHEN aeadEncrypt(algo, key, pt, { nonce }) is called with no engine option
  • THEN the call is delegated to a NobleAeadEngine instance

Scenario: Custom engine substituted

  • WHEN aeadEncrypt(algo, key, pt, { nonce }, { engine: stub }) is called with a stub engine
  • THEN stub.encrypt(...) is invoked AND the default Noble engine is NOT touched

Verified by: src/crypto.spec-conformance.test.ts::"default engine wired when no engine option is supplied (aead, sign, agree, sealed-box, auth-box)" AND src/aead/aead.test.ts::"passes call through to a custom engine" AND src/sealed-box/sealed-box.test.ts::"uses provided engine" AND src/auth-box/auth-box.test.ts::"uses provided engine"

Requirement: AEAD round-trip across all three algorithms

The package SHALL export aeadEncrypt and aeadDecrypt dispatching by algorithm string "AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305". A successful encrypt followed by decrypt with the same key, nonce, and AAD SHALL return bytes byte-equal to the original plaintext.

Scenario: AES-256-GCM round-trip

  • WHEN aeadDecrypt("AES-256-GCM", key, aeadEncrypt("AES-256-GCM", key, p, { nonce, aad }), { nonce, aad }) runs
  • 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 p

Scenario: XChaCha20-Poly1305 round-trip

  • WHEN the same round-trip is performed with "XChaCha20-Poly1305" (24-byte nonce)
  • THEN the returned bytes equal p

Verified by: src/aead/aead.test.ts::"round-trips plaintext" (parameterised over all three algorithms) AND src/aead/aead.test.ts::"property: arbitrary payloads round-trip"

Requirement: AEAD nonce is caller-supplied — never internally generated

The package SHALL require the caller to supply options.nonce to aeadEncrypt. The package SHALL NOT generate, randomise, or default the nonce internally. Nonce reuse is a caller-side correctness problem; the façade explicitly refuses to hide it.

Scenario: AES-256-GCM / ChaCha20-Poly1305 require a 12-byte nonce

  • WHEN the supplied nonce.length !== 12 for AES-256-GCM or ChaCha20-Poly1305
  • THEN aeadEncrypt throws CryptoError with code CRYPTO.AEAD.INVALID_NONCE

Scenario: XChaCha20-Poly1305 requires a 24-byte nonce

  • WHEN the supplied nonce.length !== 24 for XChaCha20-Poly1305
  • THEN aeadEncrypt throws CryptoError with code CRYPTO.AEAD.INVALID_NONCE

Scenario: Wrong key length rejected

  • WHEN aeadEncrypt is called with key.length !== 32
  • THEN it throws CryptoError with code CRYPTO.KEY.INVALID_LENGTH

Scenario: Façade source contains no internal nonce generation

  • WHEN src/aead/ is scanned for randomBytes, getRandomValues, or any byte-source consumption
  • THEN no match is found — the AEAD surface never touches randomness

Verified by: src/aead/aead.test.ts::"rejects wrong nonce length for AES-256-GCM" AND src/aead/aead.test.ts::"rejects wrong nonce length for ChaCha20-Poly1305" AND src/aead/aead.test.ts::"rejects wrong nonce length for XChaCha20-Poly1305" AND src/aead/aead.test.ts::"rejects wrong key length" AND src/crypto.spec-conformance.test.ts::"AEAD façade source never consumes a byte source — nonce is caller-supplied"

Requirement: AEAD authentication failure is generic and leaks no plaintext

When aeadDecrypt fails authentication (tampered ciphertext, wrong tag, wrong key, wrong AAD), it SHALL throw CryptoError with code CRYPTO.AEAD.DECRYPT_FAILED. The error's context SHALL contain at most { algorithm, ciphertextLength } — never plaintext bytes, key bytes, tag bytes, or partial ciphertext.

Scenario: Tampered ciphertext rejected

  • WHEN a single byte of a ciphertext produced by aeadEncrypt is flipped and passed to aeadDecrypt
  • THEN CryptoError is thrown with code CRYPTO.AEAD.DECRYPT_FAILED AND no plaintext bytes are returned

Scenario: Decryption-failure context is generic

  • WHEN aeadDecrypt throws CRYPTO.AEAD.DECRYPT_FAILED
  • THEN the error context keys are a subset of { "algorithm", "ciphertextLength" }

Verified by: src/aead/aead.test.ts::"tampered ciphertext rejected with CRYPTO.AEAD.DECRYPT_FAILED" AND src/aead/aead.test.ts::"AAD is authenticated" AND src/crypto.spec-conformance.test.ts::"AEAD decryption failure leaks no plaintext, key, or tag bytes in error context"

Requirement: Ed25519 sign / verify round-trip and rejection

The package SHALL export an ed25519 namespace with generateKeypair, sign(secretKey, message), and verify(publicKey, signature, message). verify SHALL return a boolean — never throw on a bad signature.

Scenario: Ed25519 sign / verify round-trip

  • WHEN ed25519.verify(pk, ed25519.sign(sk, m), m) is called for keypair (pk, sk)
  • THEN it returns true

Scenario: Ed25519 mismatched message returns false

  • WHEN ed25519.verify(pk, signatureOfM, otherMessage) is called
  • THEN it returns false (does NOT throw)

Verified by: src/sign/ed25519.test.ts AND src/crypto.spec-conformance.test.ts::"ed25519 verify returns false (never throws) on a wrong message"

Requirement: ECDSA-P256 sign / verify round-trip and rejection

The package SHALL export an ecdsaP256 namespace with generateKeypair, sign(secretKey, message), and verify(publicKey, signature, message). sign SHALL hash message with SHA-256 internally and return a compact 64-byte (r||s) signature. verify SHALL return a boolean — never throw on a bad signature.

Scenario: ECDSA-P256 sign / verify round-trip

  • WHEN ecdsaP256.verify(pk, ecdsaP256.sign(sk, m), m) is called
  • THEN it returns true

Scenario: ECDSA-P256 mismatched message returns false

  • WHEN ecdsaP256.verify(pk, signatureOfM, otherMessage) is called
  • THEN it returns false

Verified by: src/sign/ecdsa-p256.test.ts AND src/crypto.spec-conformance.test.ts::"ecdsaP256 verify returns false (never throws) on a wrong message"

Requirement: X25519 shared-secret stability

The package SHALL export an x25519 namespace with generateKeypair and agree(mySecret, theirPublic). The agreement SHALL be symmetric (agree(a.sk, b.pk) === agree(b.sk, a.pk)) and the result SHALL be a 32-byte Uint8Array.

Scenario: Shared-secret symmetry

  • WHEN agree(alice.secretKey, bob.publicKey) and agree(bob.secretKey, alice.publicKey) are computed for two freshly-generated keypairs
  • THEN the two resulting 32-byte secrets are byte-equal

Scenario: Stable across repeated calls

  • WHEN agree(sk, pk) is called twice with the same inputs
  • THEN both invocations return byte-identical output

Verified by: src/agree/x25519.test.ts::"derives a symmetric shared secret" AND src/crypto.spec-conformance.test.ts::"x25519 agree is stable: repeated calls with the same inputs produce byte-identical output"

Requirement: Sealed-box authenticity (anonymous sender)

The package SHALL export a sealedBox namespace with seal(recipientPublic, plaintext) and open(recipientSecret, recipientPublic, ciphertext). The blob layout SHALL be ephemeralPub(32) || nonce(24) || ciphertext || tag(16). open SHALL throw CryptoError on tamper, wrong key, or short input — it SHALL NOT return any plaintext on failure.

Scenario: Sealed-box round-trip

  • WHEN open(recipient.secretKey, recipient.publicKey, seal(recipient.publicKey, msg)) is called
  • THEN the returned bytes equal msg

Scenario: Two seals of the same plaintext differ

  • WHEN seal(recipientPublic, msg) is called twice with identical inputs
  • THEN the two ciphertexts are NOT byte-equal (random ephemeral keypair / nonce)

Scenario: Wrong recipient secret rejected

  • WHEN open(wrong.secretKey, wrong.publicKey, ct) is called for a sealed box NOT addressed to wrong
  • THEN CryptoError is thrown AND no plaintext is returned

Verified by: src/sealed-box/sealed-box.test.ts::"round-trips plaintext" AND src/sealed-box/sealed-box.test.ts::"two seals of the same plaintext produce different ciphertext (random nonce)" AND src/sealed-box/sealed-box.test.ts::"wrong recipient secret key rejected" AND src/sealed-box/sealed-box.test.ts::"tampered ciphertext rejected"

Requirement: Auth-box authenticates the sender

The package SHALL export an authBox namespace with seal(senderSecret, recipientPublic, plaintext, nonce) and open(senderPublic, recipientSecret, ciphertext, nonce). open SHALL fail with CryptoError when the supplied senderPublic does not match the keypair that produced the ciphertext.

Scenario: Auth-box round-trip

  • WHEN open(alice.publicKey, bob.secretKey, seal(alice.secretKey, bob.publicKey, msg, nonce), nonce) is called
  • THEN the returned bytes equal msg

Scenario: Wrong sender public key rejected

  • WHEN open(carol.publicKey, bob.secretKey, ctFromAliceToBob, nonce) is called
  • THEN CryptoError is thrown

Scenario: Wrong nonce rejected

  • WHEN the nonce passed to open differs from the nonce used in seal
  • THEN CryptoError is thrown

Verified by: src/auth-box/auth-box.test.ts::"round-trips plaintext" AND src/auth-box/auth-box.test.ts::"wrong sender public key rejected" AND src/auth-box/auth-box.test.ts::"wrong nonce rejected"

Requirement: Constant-time byte comparison

The package SHALL export constantTimeEqual(a, b) returning true iff a and b have the same length AND every byte is equal. The implementation SHALL accumulate differences with bitwise XOR over the full buffer length without short-circuiting on the first mismatch.

Scenario: Equal buffers compare equal

  • WHEN constantTimeEqual(a, b) is called with two byte-equal buffers
  • THEN it returns true

Scenario: Unequal length returns false

  • WHEN constantTimeEqual(a, b) is called with a.length !== b.length
  • THEN it returns false

Scenario: No early-exit branch on byte mismatch

  • WHEN the source of constantTimeEqual is statically inspected
  • THEN the byte-compare loop contains no return or break — only an XOR accumulator

Verified by: src/primitives/constant-time-equal.test.ts AND src/crypto.spec-conformance.test.ts::"constantTimeEqual loop body never short-circuits — XOR accumulator only"

Requirement: Zeroization

The package SHALL export zeroize(buf) that fills buf with zero bytes in place. The function SHALL accept any Uint8Array including empty ones.

Scenario: In-place zero on a populated buffer

  • WHEN zeroize(x) is called on a non-empty Uint8Array
  • THEN every element of x equals 0 afterwards AND the same Uint8Array reference is mutated (no copy)

Verified by: src/primitives/zeroize.test.ts AND src/crypto.spec-conformance.test.ts::"zeroize mutates in place — same buffer reference, every byte 0"

Requirement: Single CSPRNG callsite

The package SHALL contain exactly one call to crypto.getRandomValues in non-test source code, located in src/primitives/random-bytes.ts. Every other module that needs random bytes SHALL invoke randomBytes() or accept a ByteSource injection. randomBytes(n, { byteSource }) SHALL prefer the injected source over the default.

Scenario: Injected byteSource used

  • WHEN randomBytes(8, { byteSource: (n) => new Uint8Array(n).fill(0xab) }) is called
  • THEN it returns 8 bytes of 0xab

Scenario: Negative or non-integer length rejected

  • WHEN randomBytes(-1) or randomBytes(1.5) or randomBytes(NaN) is called
  • THEN CryptoError is thrown with code CRYPTO.RNG.INVALID_LENGTH

Scenario: Façade has exactly one crypto.getRandomValues callsite

  • WHEN src/ is scanned for crypto.getRandomValues callsites (excluding test files and comments)
  • THEN exactly one match is found, located in src/primitives/random-bytes.ts

Verified by: src/primitives/random-bytes.test.ts AND src/crypto.spec-conformance.test.ts::"src/ contains exactly one crypto.getRandomValues callsite, in src/primitives/random-bytes.ts"

Requirement: Structured errors with stable code registry

All thrown errors SHALL be CryptoError instances with a code field drawn from the frozen CRYPTO_CODES registry. Every code SHALL be prefixed CRYPTO.. Every registered code SHALL be thrown by at least one production callsite.

Scenario: Registry is frozen

  • WHEN a consumer attempts to mutate CRYPTO_CODES
  • THEN the assignment throws in strict mode

Scenario: Every registered code has a callsite

  • WHEN src/ is statically scanned for each entry of CRYPTO_CODES
  • THEN every code appears in at least one non-test source file

Verified by: src/errors/crypto-error.test.ts AND src/crypto.spec-conformance.test.ts::"every CRYPTO_CODES entry is thrown from at least one non-test source file"

Requirement: 100% test coverage

vitest.config.ts MUST enforce statements: 100, branches: 100, functions: 100, lines: 100. Property-based tests (fast-check) SHALL cover AEAD round-trip, sealed-box round-trip, and auth-box round-trip.

Scenario: Coverage threshold enforced

  • WHEN bunx turbo test:coverage --filter=@suaveplan/crypto runs
  • THEN it exits non-zero if any metric drops below 100

Verified by: vitest.config.ts (coverage thresholds) AND bunx turbo test:coverage --filter=@suaveplan/crypto