Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
# OpenSpec Change: crypto-spec-conformance
- **Change ID**: crypto-spec-conformance
- **Package**: `@suaveplan/crypto`
- **Status**: PROPOSED
- **Date**: 2026-04-26
## Summary
Realign the folded `openspec/specs/crypto/spec.md` with the actual surface of `@suaveplan/crypto` (the multi-algorithm façade), add a cross-cutting `src/crypto.spec-conformance.test.ts`, and annotate every requirement with a `**Verified by:**` line so `bun scripts/openspec-conformance.ts @suaveplan/crypto` exits 0. Two prior archived change folders (`2026-04-19-crypto-primitive-replaceability`, `2026-04-19-enterprise-crypto`) carry `retroactively ticked` markers that the conformance script downgrades to `info` once a `*-spec-conformance` rectification exists. No public-API changes.
## Current state
`@suaveplan/crypto` is the engine-pluggable façade composing the per-primitive subpackages (`crypto-utils`, `crypto-hash`, `crypto-curve`, `crypto-cipher`). Its surface is AEAD (3 algorithms), Ed25519 / ECDSA-P256 sign-verify, X25519 agreement, sealed-box, auth-box, `constantTimeEqual`, `zeroize`, `randomBytes` (single CSPRNG callsite), and a `CRYPTO_CODES` registry. Per-feature unit tests cover the happy paths, AAD authentication, tampered-ciphertext rejection, key/nonce length validation, fast-check round-trip properties, and engine substitution. The conformance gaps are:
- **Spec drift.** The folded `openspec/specs/crypto/spec.md` was the original `enterprise-crypto` proposal — it specs `sha256`, `sha384`, `sha512`, `hmac`, `hkdf`, `pbkdf2`, `argon2id` as exports of this façade. After the per-primitive split those primitives moved to `@suaveplan/crypto-hash`. The `Purpose` section is the literal placeholder `TBD - created by archiving change enterprise-crypto. Update Purpose after archive.` All 13 requirements lack `**Verified by:**` lines.
- **Implicit cross-cutting invariants are unpinned.** Engine pluggability across all five operation namespaces, the AEAD nonce-is-caller-supplied invariant (no internal randomness in `src/aead/`), `verify` returning a boolean (never throwing) on bad signatures, the single-`crypto.getRandomValues`-callsite property, and the structured-error-no-leak rule for AEAD failures are all stated by the spec but not directly asserted by any test.
- **Retroactive ticking debt.** Archived change folders `2026-04-19-crypto-primitive-replaceability/tasks.md` and `2026-04-19-enterprise-crypto/tasks.md` contain `retroactively ticked`, which the conformance script flags as `error` until a `*-spec-conformance` rectification folder exists.
## What changes
1. **Rewrite `openspec/specs/crypto/spec.md`** to reflect the actual façade surface:
- Fill the `## Purpose` section.
- Replace the drifted hash / KDF / argon2id requirements (which now live in `@suaveplan/crypto-hash`'s spec) with thirteen requirements that match the façade: engine pluggability, AEAD round-trip across three algorithms, AEAD nonce-caller-supplied invariant, AEAD authentication-failure leak rule, Ed25519 / ECDSA-P256 sign-verify with `verify` boolean contract, X25519 stability, sealed-box authenticity, auth-box sender authentication, constant-time-equal, zeroize, single CSPRNG callsite, structured-errors-with-stable-registry, and 100% coverage gate.
- Add a `**Verified by:**` line to every `### Requirement:` block pointing at concrete `*.test.ts::"name"` test cases.
2. **Add `src/crypto.spec-conformance.test.ts`** covering:
- Default-engine wiring across all five namespaces.
- Custom-engine substitution suppresses the default for sign and agree.
- AEAD façade source contains zero byte-source consumption (`grep` over `src/aead/`).
- AEAD decryption-failure error context is restricted to `{ algorithm, ciphertextLength }`.
- Ed25519 / ECDSA-P256 `verify` returns `false` (never throws) on a wrong message.
- X25519 agreement is byte-stable across repeated calls.
- `constantTimeEqual` source contains no `return` / `break` inside its compare loop — only an XOR accumulator.
- `zeroize` mutates in place (same `Uint8Array` reference).
- Exactly one `crypto.getRandomValues(...)` callsite in `src/`, located in `src/primitives/random-bytes.ts`.
- Every `CRYPTO_CODES` entry is referenced from at least one non-test source file.
- `CRYPTO_CODES` is frozen.
- Engine type exports compile.
3. **Rectify retroactive-ticking debt.** Creating this `crypto-spec-conformance` change folder downgrades the prior `retroactively ticked` markers in archived task lists from `error` to `info` per the conformance-script policy.
## Impact
- Public API unchanged.
- One new test file (~330 lines, under the 500-line budget).
- `openspec/specs/crypto/spec.md` rewritten to reflect the actual façade surface; the prior text described primitives that have since moved to `@suaveplan/crypto-hash`.
- Coverage stays at 100/100/100/100; the new tests exercise existing production code paths plus structural source-text invariants.
- No new runtime dependencies.

View File

@@ -0,0 +1,256 @@
# 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`

View File

@@ -0,0 +1,8 @@
# Tasks — crypto-spec-conformance
- [x] Rewrite `openspec/specs/crypto/spec.md` to reflect the actual façade surface (AEAD, sign, agree, sealed-box, auth-box, primitives, errors, RNG callsite). Fill `## Purpose`. Drop the moved hash / KDF / argon2id requirements (those now belong to `@suaveplan/crypto-hash`'s spec).
- [x] Add `**Verified by:**` line to every `### Requirement:` block in the folded spec.
- [x] Add `src/crypto.spec-conformance.test.ts` covering engine pluggability across all namespaces, AEAD nonce invariant, AEAD-failure no-leak, sign-verify boolean contract, x25519 stability, primitives loop body / mutation invariants, single `crypto.getRandomValues` callsite, error registry coverage, and frozen registry.
- [x] Confirm `bun scripts/openspec-conformance.ts @suaveplan/crypto` exits 0.
- [x] Confirm `bunx turbo lint typecheck test --filter=@suaveplan/crypto` is green.
- [x] Confirm `bunx turbo test:coverage --filter=@suaveplan/crypto` reports 100/100/100/100.