Files
2026-05-27 23:29:53 +01:00

257 lines
14 KiB
Markdown

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