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 noengineoption - THEN the call is delegated to a
NobleAeadEngineinstance
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 !== 12for AES-256-GCM or ChaCha20-Poly1305 - THEN
aeadEncryptthrowsCryptoErrorwith codeCRYPTO.AEAD.INVALID_NONCE
Scenario: XChaCha20-Poly1305 requires a 24-byte nonce
- WHEN the supplied
nonce.length !== 24for XChaCha20-Poly1305 - THEN
aeadEncryptthrowsCryptoErrorwith codeCRYPTO.AEAD.INVALID_NONCE
Scenario: Wrong key length rejected
- WHEN
aeadEncryptis called withkey.length !== 32 - THEN it throws
CryptoErrorwith codeCRYPTO.KEY.INVALID_LENGTH
Scenario: Façade source contains no internal nonce generation
- WHEN
src/aead/is scanned forrandomBytes,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
aeadEncryptis flipped and passed toaeadDecrypt - THEN
CryptoErroris thrown with codeCRYPTO.AEAD.DECRYPT_FAILEDAND no plaintext bytes are returned
Scenario: Decryption-failure context is generic
- WHEN
aeadDecryptthrowsCRYPTO.AEAD.DECRYPT_FAILED - THEN the error
contextkeys 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)andagree(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 towrong - THEN
CryptoErroris 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
CryptoErroris thrown
Scenario: Wrong nonce rejected
- WHEN the nonce passed to
opendiffers from the nonce used inseal - THEN
CryptoErroris 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 witha.length !== b.length - THEN it returns
false
Scenario: No early-exit branch on byte mismatch
- WHEN the source of
constantTimeEqualis statically inspected - THEN the byte-compare loop contains no
returnorbreak— 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-emptyUint8Array - THEN every element of
xequals0afterwards AND the sameUint8Arrayreference 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)orrandomBytes(1.5)orrandomBytes(NaN)is called - THEN
CryptoErroris thrown with codeCRYPTO.RNG.INVALID_LENGTH
Scenario: Façade has exactly one crypto.getRandomValues callsite
- WHEN
src/is scanned forcrypto.getRandomValuescallsites (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 ofCRYPTO_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/cryptoruns - 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