# crypto Specification ## Purpose TBD - created by archiving change enterprise-crypto. Update Purpose after archive. ## 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 ### Requirement: Primitive-surface symbols sourced from `@suaveplan/crypto-core` `@suaveplan/crypto` SHALL NOT contain its own implementation of the cryptographic primitives (AEAD, key agreement, signing, sealed-box, auth-box, raw key generation). Every primitive symbol exposed in the package's public surface MUST be a typed re-export from `@suaveplan/crypto-core`. The opinionated modules (`keychain`, `derivation`, `bundles`, `domain-tags`, `authority`) MAY internally import from `@suaveplan/crypto-core` but MUST NOT re-implement primitive operations. #### Scenario: no primitive impl files remain in `crypto/src/` - **WHEN** the test scans `crypto/src/` for files under `aead/`, `agree/`, `auth-box/`, `sealed-box/`, `sign/`, `primitives/`, or `engines/` - **THEN** zero files are found #### Scenario: every previously-public primitive symbol is still importable - **WHEN** a consumer imports any of `aead`, `ed25519`, `x25519`, `sealedBox`, `authBox`, `randomBytes`, `zeroize`, `constantTimeEqual`, etc. from `@suaveplan/crypto` - **THEN** the symbol resolves and has the same type signature it had before this change #### Scenario: barrel diff is byte-identical - **WHEN** the post-change `dist/index.d.ts` is diffed against the pre-change snapshot at `tests/before.dts.txt` - **THEN** the diff is zero lines (the public type surface is unchanged) ### Requirement: Keypair, Keychain storage, BIP39, and legacy Vault live inside `@suaveplan/crypto` `@suaveplan/crypto` SHALL own the keypair generation/validation surface, the in-memory and serialized keychain storage adapters, the BIP39 mnemonic generation/restoration helpers, and the existing pre-Authority `Vault` class. The packages `@suaveplan/crypto-keys` and `@suaveplan/crypto-vault` MUST be deleted; their content is folded under `crypto/src/keychain/` and `crypto/src/vault/` respectively. To avoid name collision with the new opinionated 8-slot `Keychain`, the folded simple-storage `Keychain` class is renamed `KeypairStore` (and its adapter interface to `KeypairStoreAdapter`). To avoid collision with the future three-regime `Vault` (slice 3), the folded class is renamed `LegacyVault` and lives transitionally at `crypto/src/vault/legacy-vault/`. The misleadingly-named `ChainSigner` from `crypto-vault` MUST be dropped (it does not actually sign under a hierarchy); slice 3 introduces `Authority` to take its place. #### Scenario: keypair generation surface available - **WHEN** a consumer imports `Keypair`, `generateKeypair`, `validateKeypair` from `@suaveplan/crypto` - **THEN** all three resolve and behave identically to their pre-fold equivalents in `@suaveplan/crypto-keys` #### Scenario: BIP39 surface available - **WHEN** a consumer imports `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed` from `@suaveplan/crypto` - **THEN** all three resolve and behave identically to their pre-fold equivalents in `@suaveplan/crypto-vault` #### Scenario: legacy Vault surface available transitionally - **WHEN** a consumer imports `LegacyVault` from `@suaveplan/crypto/vault/legacy-vault` - **THEN** it resolves and behaves identically to the pre-fold `crypto-vault` `Vault` class (renamed for collision avoidance) #### Scenario: ChainSigner is gone - **WHEN** a consumer attempts to import `ChainSigner` from any `@suaveplan/crypto*` package - **THEN** the import fails (the symbol does not exist anywhere in genesis) #### Scenario: deleted packages no longer referenced - **WHEN** the test runs `grep -rln "@suaveplan/crypto-keys\|@suaveplan/crypto-vault" packages/` - **THEN** zero matches are found (excluding `openspec/archive/`) ### Requirement: Opinionated layer modules — Identity, Revocation, Audit, Packer, Vault `@suaveplan/crypto` SHALL ship the seven opinionated-layer modules: `identity` (root + server identities + lifecycle ops), `revocation` (`RevocationList` — root-signed CRL), `audit-log` (append-only Merkle log), `packer` (`Packer` / `Unpacker` encrypt-and-sign envelopes with replay protection), `chunked-packer` (`ChunkedPacker` / `ChunkedUnpacker` per-chunk-signed P2P fragmentation), `vault` (three-regime serialisation), and `wire-format` (committed schemas + magic bytes). Every module exposes its surface via the package barrel and via a dedicated subpath export. #### Scenario: identity hierarchy operations available - **WHEN** a consumer imports `createRoot`, `createServer`, `rollServer`, `revokeServer` from `@suaveplan/crypto` - **THEN** all four resolve and produce typed `RootIdentity` / `ServerIdentity` / `RolledServer` / `RevokedServer` values #### Scenario: encrypt-and-sign envelopes round-trip with replay protection - **WHEN** `Packer.pack` then `Unpacker.unpack` runs with matching parties, AEAD key, nonce, and domain tag - **THEN** the unpacked plaintext equals the original; replay of the same envelope rejects with a coded error #### Scenario: chunked transport detects tampering and reassembles in order - **WHEN** `ChunkedPacker.pack` produces N chunks and one chunk's data byte is flipped - **THEN** `ChunkedUnpacker.reassemble` throws a coded error citing the tampered chunk index #### Scenario: revocation list verifies under root authority - **WHEN** a `RevocationList` is appended to and verified against the issuing root's public sign slot - **THEN** verification succeeds; mutating any past entry causes verification to throw #### Scenario: audit log root signature detects tampering - **WHEN** an `AuditLog` is signed and any past event is then mutated - **THEN** subsequent `verify` throws `CryptoError(AUTHORITY.REVOKED)` with `audit.verify.root_mismatch` #### Scenario: three-regime vault primitives round-trip - **WHEN** `sealPrivateKeyBlob` then `openPrivateKeyBlob` runs with the same password - **THEN** the bytes round-trip; `changePassword` rotates the AEAD key without re-deriving the secret #### Scenario: wire-format barrel re-exports every committed schema - **WHEN** a consumer imports from `@suaveplan/crypto/wire-format` - **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, and the `*_FORMAT_VERSION` constants are all importable ### Requirement: `keychain` CLI surface and TTY-only password input `@suaveplan/crypto-cli` SHALL ship a `keychain` binary exposing eight subcommands: `root init`, `server init`, `server roll`, `server revoke`, `inspect`, `verify`, `export-public`, `restore`. Every subcommand MUST be invokable with the documented argv shape; the CLI MUST reject `--password` flag input with a security-pointer error and accept passwords only via TTY prompt or `KEYCHAIN_PASSWORD` env var. The functional API in `@suaveplan/crypto-cli` (`rootInit`, `serverInit`, `serverRoll`, `serverRevokeCmd`, `inspect`, `verify`, `exportPublic`, `restore`) is the canonical surface; the bin entry is a thin argv parser that dispatches to it. #### Scenario: every documented subcommand exists and is invokable - **WHEN** the bin is invoked with `--help` - **THEN** all eight subcommand strings appear in the printed help text #### Scenario: `--password` flag rejected - **WHEN** any invocation includes `--password` or `--password=value` - **THEN** the bin exits with code 2 and a security-pointer error pointing at the TTY/env-var alternatives #### Scenario: state-changing commands honour idempotency keys - **WHEN** `serverRoll` (or `serverRevokeCmd`) is invoked twice with the same `idempotencyKey` and a shared ledger - **THEN** the second invocation is a no-op (returns the same identity / does not re-revoke) ### Requirement: Import-allowlist conformance gate The repository SHALL ship `scripts/conformance/check-crypto-core-allowlist.ts`, a script that scans every workspace package's `src/` for imports of `@suaveplan/crypto-core`, `@suaveplan/crypto-cipher`, `@suaveplan/crypto-curve`, or `@suaveplan/crypto-hash/mac` and fails when a package outside the committed allowlist (`scripts/conformance/crypto-core-allowlist.yml`) imports any of them. Encoding helpers from `@suaveplan/crypto-utils` and pure-hash uses of `@suaveplan/crypto-hash` (sha256 / sha384 / sha512 / hkdf / pbkdf2 / argon2id) MUST remain open. #### Scenario: monorepo currently has zero violations - **WHEN** `bun scripts/conformance/check-crypto-core-allowlist.ts` runs - **THEN** exit code is 0 and stdout reports `crypto-core allowlist: 0 violations` #### Scenario: a non-allowlisted package importing a primitive is flagged - **WHEN** a fixture package outside the allowlist imports `aead` from `@suaveplan/crypto-core` - **THEN** the script exits non-zero and lists the violating import as an `[error]` finding citing the package, file, line, and import path ### Requirement: `slsa` no longer imports the opinionated `@suaveplan/crypto` `@suaveplan/slsa` MUST NOT import from `@suaveplan/crypto`. Direct primitive imports go through `@suaveplan/crypto-core` (DSSE PAE verification) and `slsa` is added to the import-allowlist with the rationale that DSSE is governed by an external spec without Suaveplan domain tags. Builder-identity SIGNING (a future change) goes through `Authority`. #### Scenario: `slsa` imports `@suaveplan/crypto-core` for ed25519 verification - **WHEN** the test scans `slsa/src/` for `@suaveplan/crypto"` imports - **THEN** zero matches are found (only `@suaveplan/crypto-core`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils` remain) ### Requirement: High-level streaming AEAD façade The package SHALL expose `encryptStreamAead(opts)` and `decryptStreamAead(opts)` from `aead/streaming/streaming-aead.ts` that select the engine (`xchacha20-poly1305` or `aes-256-gcm`) from `opts.algorithm` and delegate to the corresponding `StreamingAeadEngine` in `@suaveplan/crypto-cipher`. The package SHALL re-export the named engines `aesGcmStreamingEngine` and `xchacha20StreamingEngine` so callers wanting the engine directly do not need to depend on `crypto-cipher`. #### Scenario: facade routes to AES-GCM engine - **WHEN** `encryptStreamAead({ algorithm: "aes-256-gcm", key, baseNonce: 8-byte, plaintext })` is called - **THEN** the produced ciphertext decrypts via the standalone `aesGcmStreamingEngine` exported from this package, byte-for-byte #### Scenario: facade routes to XChaCha20-Poly1305 engine - **WHEN** `encryptStreamAead({ algorithm: "xchacha20-poly1305", key, baseNonce: 20-byte, plaintext })` is called - **THEN** the produced ciphertext decrypts via the standalone `xchacha20StreamingEngine` exported from this package, byte-for-byte #### Scenario: unknown algorithm rejected - **WHEN** `encryptStreamAead({ algorithm: "rot13" as never, ... })` is called - **THEN** it throws `SuaveplanError` with code `CRYPTO.STREAM_AEAD_HEADER_INVALID` and `context.algorithm === "rot13"` **Verified by:** `packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes aes-256-gcm to aesGcmStreamingEngine"` AND `packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes xchacha20-poly1305 to xchacha20StreamingEngine"` AND `packages/crypto/crypto/src/aead/streaming/streaming-aead.spec-conformance.test.ts::"throws STREAM_AEAD_HEADER_INVALID on unknown algorithm"` --- ### Requirement: Web-Streams adapters for streaming AEAD The package SHALL expose `createEncryptionStream(opts) → TransformStream` and `createDecryptionStream(opts) → TransformStream` from `aead/streaming/web-streams.ts`. These SHALL be drop-in `pipeThrough` targets for any Web-Streams `ReadableStream` source (e.g. `Blob.stream()`, `Response.body`, `fetch().body`). The package SHALL also expose `collectStream(stream) → Promise` (concatenates all chunks), `createStreamKey(rng?) → Uint8Array` (returns 32 random bytes), and `createStreamEncryption(opts)` (combined key + base-nonce + initialised stream factory). #### Scenario: pipeThrough round-trips a Blob - **WHEN** `blob.stream().pipeThrough(createEncryptionStream(opts)).pipeThrough(createDecryptionStream(opts))` is collected via `collectStream` - **THEN** the collected bytes equal the original blob bytes #### Scenario: legacy aliases preserved - **WHEN** `streamEncrypt`, `streamDecrypt`, `ChaCha20Stream`, `AESStream` are imported - **THEN** all four resolve to defined values that satisfy the documented upstream contract **Verified by:** `packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"pipeThrough round-trips a multi-chunk source through encrypt then decrypt"` AND `packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"streamEncrypt / streamDecrypt / ChaCha20Stream / AESStream aliases resolve and round-trip"` --- ### Requirement: Password-based encryption helpers The package SHALL expose `encryptWithPassword(password, plaintext, options?)` and `decryptWithPassword(password, ciphertext, options?)` from `password/encrypt-with-password.ts`. The `options.kdf` field SHALL accept `"argon2id"` (default), `"scrypt"`, or `"pbkdf2"`. The output blob SHALL begin with a versioned 16-byte self-describing header `{ magic: "SVPP", version: 1, kdf, cipher, reserved, saltLen, nonceLen, kdfParamsLen }` followed by salt, nonce, CBOR-encoded `kdfParams`, then AEAD ciphertext. `decryptWithPassword` SHALL parse the header to recover all parameters and SHALL NOT require any out-of-band metadata from the caller. #### Scenario: round-trip with default Argon2id - **WHEN** `encryptWithPassword("correct horse battery staple", plaintext)` produces `blob` and `decryptWithPassword("correct horse battery staple", blob)` is called - **THEN** the result equals `plaintext` byte-for-byte #### Scenario: round-trip with scrypt KDF - **WHEN** `encryptWithPassword(pw, plaintext, { kdf: "scrypt" })` produces `blob` and `decryptWithPassword(pw, blob)` is called - **THEN** the result equals `plaintext` byte-for-byte AND the blob's KDF byte signals scrypt #### Scenario: blob header self-describes - **WHEN** the same `(password, plaintext)` is encrypted twice with different `kdfParams` - **THEN** the two blobs have different KDF-params bytes in their headers AND both decrypt successfully without the caller passing `kdfParams` to `decryptWithPassword` #### Scenario: unknown KDF rejected - **WHEN** `encryptWithPassword(pw, plaintext, { kdf: "bcrypt" as never })` is called - **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_KDF_UNKNOWN` and `context.kdf === "bcrypt"` #### Scenario: corrupt header rejected - **WHEN** `decryptWithPassword(pw, blob)` is called with a `blob` whose first four bytes are not `"SVPP"` - **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_BLOB_HEADER_INVALID` #### Scenario: unsupported version rejected - **WHEN** `decryptWithPassword(pw, blob)` is called with a `blob` whose version byte is `99` - **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTED` and `context.version === 99` **Verified by:** `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with default Argon2id KDF"` AND `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with scrypt KDF"` AND `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"blob header is self-describing across kdfParams variants"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_KDF_UNKNOWN on unknown KDF name"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_HEADER_INVALID on corrupt magic bytes"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_VERSION_UNSUPPORTED on unknown version byte"` --- ### Requirement: deriveKeyFromPassword The package SHALL expose `deriveKeyFromPassword(password, salt, options?)` from `password/derive-key-from-password.ts`. The `options` SHALL accept `kdf: "argon2id" | "scrypt" | "pbkdf2"` (default `"argon2id"`), `kdfParams: Record` (algorithm-specific), and `dkLen: number` (default 32). The function SHALL return a `Uint8Array` of length `dkLen` suitable for direct use as a symmetric AEAD key. Identical `(password, salt, kdf, kdfParams)` SHALL produce byte-identical output. #### Scenario: deterministic given fixed inputs - **WHEN** `deriveKeyFromPassword(pw, salt, { kdf: "scrypt", kdfParams: { N: 1024, r: 8, p: 1 } })` is called twice - **THEN** both calls return byte-identical 32-byte Uint8Arrays #### Scenario: dkLen honored - **WHEN** `deriveKeyFromPassword(pw, salt, { kdf: "argon2id", dkLen: 64 })` is called - **THEN** the result is a 64-byte Uint8Array **Verified by:** `packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"deterministic for fixed password / salt / params"` AND `packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"honors dkLen option"` ### Requirement: ChainBundle wire format is byte-identical to v0x0001 layout The `packChainBundle(linkBytes)` function SHALL produce bytes in the layout `[CHAIN_BUNDLE_MAGIC (4 bytes)][CHAIN_BUNDLE_FORMAT_VERSION (u8)][linkCount (u8)][...linkBytes concatenated]`. `CHAIN_BUNDLE_HEADER_BYTE_SIZE` SHALL be `6` (4 magic + 1 version + 1 linkCount). The implementation MAY consolidate the header construction through `@suaveplan/bitpacker-crypto::EnvelopeSchema`, but the resulting bytes MUST be byte-identical to the v0x0001 layout pinned by snapshot test. #### Scenario: snapshot bytes match v0x0001 layout - **WHEN** `packChainBundle([fixedDeterministicLinkBytes])` runs with a fixed deterministic link payload - **THEN** the resulting bytes are byte-identical to the v0x0001 snapshot pinned in `chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` #### Scenario: header size constant is 6 - **WHEN** `CHAIN_BUNDLE_HEADER_BYTE_SIZE` is read - **THEN** the value is exactly `6` #### Scenario: bad magic surfaces as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bad-magic` - **WHEN** `parseChainBundle(bytes)` is called on bytes whose first 4 bytes do not match `CHAIN_BUNDLE_MAGIC` - **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.bad-magic` #### Scenario: bad version surfaces as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.unsupported-version` - **WHEN** `parseChainBundle(bytes)` is called on bytes whose 5th byte (formatVersion) is not `CHAIN_BUNDLE_FORMAT_VERSION` - **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.unsupported-version` and `context.version` carrying the actual version byte #### Scenario: too-short bytes surface as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bytes-too-short` - **WHEN** `parseChainBundle(bytes)` is called on a buffer shorter than `CHAIN_BUNDLE_HEADER_BYTE_SIZE` - **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.bytes-too-short` **Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"` ### Requirement: Eight-slot Keychain with full-width derivation coordinates `@suaveplan/crypto` SHALL define a `Keychain` type representing a coherent identity composed of eight default slots, each addressed by a `DerivationCoordinate` and carrying `{algorithm, notBefore, notAfter, secretKey?, publicKey?}`. The eight default slot purposes are: `master`, `encrypt`, `sign`, `kx`, `salt`, `hash`, `stream`, `device` — each represented as a 4-byte ASCII purpose tag (`"mast"`, `"encr"`, `"sign"`, `"kx "`, `"salt"`, `"hash"`, `"strm"`, `"dev "`). A `DerivationCoordinate` carries: - **`purpose`** — 4-byte ASCII (the slot type) - **`application`** — 4-byte ASCII (which product/component, e.g. `"chat"`, `"vlt "`, `"genr"` default) - **`layoutId`** — `u8` selecting which `DerivationLayout` interprets the u64 fields below - Layout-specific fields filling the remaining 64 bits (default `DERIVATION_LAYOUT_V1`: `masterVersion u16`, `userId u32`, `depth u8`, `slotVersion u8`) The `(purpose, application)` pair maps onto libsodium's 8-byte `ctx`; the four layout fields pack into libsodium's `subkey_id` (u64). Together they form the 16-byte derivation namespace this package consumes. The `algorithm` field is required and is one of `ed25519`, `ecdsa-p256`, `x25519`, `xchacha20-poly1305`, `aes-256-gcm`, `chacha20-poly1305`, `argon2id`, `hkdf-sha256` depending on slot purpose. #### Scenario: deterministic generation from a master and coordinate set - **WHEN** `generateKeychain` is called twice with the same master key and the same coordinate inputs - **THEN** both results are byte-equal across every slot #### Scenario: userId beyond the legacy 4-bit ceiling is supported - **WHEN** a caller invokes `generateKeychain({ userId: 1_000_000, ... })` - **THEN** the call succeeds — the default `DERIVATION_LAYOUT_V1` allocates 32 bits to `userId`, supporting up to ~4.3 billion distinct users per `(masterVersion, application)` pair (regression-test against the legacy 4-bit ceiling) #### Scenario: per-purpose algorithm overrides honored - **WHEN** `generateKeychain` is called with a per-purpose algorithm override map - **THEN** the resulting slot's `algorithm` field reflects the override and the slot's key material has the override's key length #### Scenario: extract public-only keychain - **WHEN** `extractPublicKeychain(keychain)` is called on a keychain with both secret and public material - **THEN** the returned keychain contains only `publicKey` per slot; every `secretKey` field is `undefined` and the result is a deep clone with no shared buffers **Verified by:** `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces all eight default slots from a deterministic master"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"deterministic: same master + coords yield byte-equal keychain"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"userId beyond the legacy 4-bit ceiling round-trips"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"honours per-purpose algorithm override"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"drops every secretKey and copies publicKey buffers"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces a deep clone with no shared buffers"` --- ### Requirement: DerivationLayout registry — pluggable u64 layout `@suaveplan/crypto` SHALL ship a `DerivationLayout` registry mapping `layoutId: u8` to a `BitSchema` describing how the 64-bit `subkey_id` half of the derivation namespace is structured. The default `DERIVATION_LAYOUT_V1` (id 0) splits as `{masterVersion: u16, userId: u32, depth: u8, slotVersion: u8}`. Consumers MAY register additional layouts via `defineDerivationLayout(id, schema)`. The `layoutId` byte is recorded in every wire-format payload that carries a `DerivationCoordinate`. Verifiers look up the layout by id at parse time; an unknown layout id is a parse failure with `KEYCHAIN.UNKNOWN_LAYOUT`. A custom layout whose `byteSize` is not exactly 8 is rejected at registration with `KEYCHAIN.LAYOUT_TOO_WIDE`. Re-registering an existing id is rejected with `KEYCHAIN.LAYOUT_ALREADY_REGISTERED`. This is what makes the package future-proof: adding a v2 layout for a new product is non-breaking; old vaults still parse under their original layout id; the verifier dispatches by id. #### Scenario: default v1 layout fills exactly 64 bits - **WHEN** the `DERIVATION_LAYOUT_V1` schema's `byteSize` is checked - **THEN** the schema reports exactly 8 bytes (16 + 32 + 8 + 8 = 64 bits) — required to fit libsodium's `subkey_id` #### Scenario: registering a custom 8-byte layout - **WHEN** `defineDerivationLayout(id, customSchema)` is called with a schema whose `byteSize === 8` - **THEN** the layout is registered and round-trips a typical record under that id #### Scenario: oversized custom layout is rejected - **WHEN** `defineDerivationLayout(id, oversizedSchema)` is called with a schema whose `byteSize > 8` - **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE` #### Scenario: undersized custom layout is rejected - **WHEN** `defineDerivationLayout(id, undersizedSchema)` is called with a schema whose `byteSize < 8` - **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE` #### Scenario: re-registration is rejected - **WHEN** `defineDerivationLayout(id, schema)` is called for an id already in the registry - **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_ALREADY_REGISTERED` #### Scenario: parsing an unknown layout id fails distinctly - **WHEN** a wire-format payload's `layoutId` is not registered - **THEN** parsing throws `CryptoError` code `KEYCHAIN.UNKNOWN_LAYOUT`, naming the unregistered id **Verified by:** `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"has byteSize === 8 (fits libsodium subkey_id)"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"v1 layout is registered at id 0 by default"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"registers a new 8-byte custom layout"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout exceeding 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout smaller than 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects re-registration with LAYOUT_ALREADY_REGISTERED"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"throws UNKNOWN_LAYOUT for unregistered id"` --- ### Requirement: Validity windows enforced at every verify call `@suaveplan/crypto` SHALL enforce per-slot `notBefore` / `notAfter` validity windows at every signature verification, AEAD decryption, and key-agreement operation. The current time is read from an injectable `Clock` provided at construction; production paths MUST NOT call `Date.now()` directly inside the keychain / authority / packer / bundle modules. A keychain slot whose validity window has expired or has not yet begun MUST cause the operation to fail with `CryptoError` code `KEYCHAIN.NOT_YET_VALID` or `KEYCHAIN.EXPIRED`. Strict mode is the default; relaxed mode (used only for archival inspection) is opt-in via an explicit `acceptExpired: true` option, and MUST NOT bypass `NOT_YET_VALID`. #### Scenario: signing with an expired slot fails - **WHEN** an `Authority` is asked to sign with a slot whose `notAfter` is before `clock.now()` - **THEN** the call throws `CryptoError` code `KEYCHAIN.EXPIRED` #### Scenario: verifying against a not-yet-valid slot fails - **WHEN** a verify path is called against a public keychain slot whose `notBefore` is after `clock.now()` - **THEN** the call throws `CryptoError` code `KEYCHAIN.NOT_YET_VALID` #### Scenario: clock exactly at notBefore or notAfter passes - **WHEN** a verify call observes `clock.now() === notBefore` or `clock.now() === notAfter` - **THEN** the verify succeeds (the window is inclusive at both endpoints) #### Scenario: relaxed mode allows reading after notAfter only - **WHEN** an inspection helper is called with `acceptExpired: true` against a slot whose `notAfter` is past - **THEN** the call succeeds; the same call against a `NOT_YET_VALID` slot still throws **Verified by:** `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.NOT_YET_VALID before notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.EXPIRED after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired allows reading after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired does NOT bypass NOT_YET_VALID"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"` --- ### Requirement: Domain-separation tag on every signed payload `@suaveplan/crypto` SHALL prepend a domain-separation tag to every payload before passing it to a signing primitive. Tags are typed as branded `string` constants exported from `domain-tags/`: `DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`, `DOMAIN_PACKER_V1`, `DOMAIN_CHUNK_V1`, `DOMAIN_CRL_V1`, `DOMAIN_AUDIT_V1`, `DOMAIN_SLSA_BUILDER_V1`, plus the bitpacker-aware tags `DOMAIN_BITPACKER_SIGNED_V1`, `DOMAIN_BITPACKER_HASHCHAIN_V1`, `DOMAIN_BITPACKER_MULTISIGN_V1`. The `ALL_DOMAIN_TAGS` registry MUST contain exactly the v1 set. Verifiers MUST require the same tag at verify time; a payload signed under tag `A` MUST fail verification if presented to a verifier expecting tag `B` (cross-protocol replay defence). The tag MUST be part of the signed bytes, not metadata: an attacker swapping the tag prefix without re-signing fails verification. `encodeDomainTag(tag)` SHALL produce ASCII bytes followed by a `0x00` terminator; `concatTagAndPayload(tag, payload)` SHALL concatenate the encoded tag and the payload bytes. #### Scenario: registry size and uniqueness - **WHEN** `ALL_DOMAIN_TAGS` is enumerated - **THEN** it contains exactly 10 tags in v1, each a unique string #### Scenario: encoded tag is ASCII-plus-NUL - **WHEN** `encodeDomainTag(tag)` is called for any registered tag - **THEN** the result is the ASCII bytes of the tag followed by a single `0x00` byte #### Scenario: distinct tags produce distinct signed inputs over the same payload - **WHEN** two different domain tags are concatenated with the same payload - **THEN** the resulting byte sequences differ #### Scenario: cross-protocol replay rejected - **WHEN** an envelope packed under `DOMAIN_VAULT_V1` is presented to an `Unpacker` configured for `DOMAIN_PACKER_V1` - **THEN** the call throws a `CryptoError` whose code identifies a domain mismatch (no plaintext is returned) **Verified by:** `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"ALL_DOMAIN_TAGS contains 10 tags in v1"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"each tag is a unique string"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces ASCII bytes followed by 0x00 terminator"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces different bytes for different domains over the same payload"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"` --- ### Requirement: Public bundles are parent-signed and carry an issuer reference `@suaveplan/crypto` SHALL provide `packPublicBundle(keychain, parentAuthority, options)` that emits a bit-packed binary bundle in the wire format below, with a trailing 64-byte ed25519 signature produced by `parentAuthority.sign(DOMAIN_PUBLIC_KEYCHAIN_V1, "sign", bundleBytes)`. The signature covers everything in the bundle including the embedded `issuerRef` — swapping the issuer reference invalidates the signature. `unpackPublicBundle(bytes, parentSignPublicKey)` verifies the trailing signature against the supplied parent public sign key, returning the parsed bundle and the verified `issuerRef`. A `parentSignPublicKey` of `null` selects self-verify mode, valid only for root identities (the bundle's own embedded `sign` slot is used). The wire format identifier is `PUBLIC_BUNDLE_MAGIC` (4 ASCII bytes), `PUBLIC_BUNDLE_FORMAT_VERSION` is `1` in v1, and the header byte size is committed as `PUBLIC_BUNDLE_HEADER_BYTE_SIZE`. The slot record layout is committed as `KEY_SLOT_PUBLIC_BYTE_SIZE` and `KEY_SLOT_PUBLIC_BYTES = 17 + 48 = 65` bytes. For a root bundle the issuer `(masterVersion, userId, depth, slotVersion)` equals the subject's, and the parent authority is the keychain itself (self-signing). Tampered bundle bytes (slot data, signature, magic, version, slotCount, length) MUST cause parse / verify to fail. #### Scenario: self-signed bundle round-trips and self-verifies - **WHEN** `packPublicBundle(rootKeychain, rootAuthority)` is called and the bundle is then verified in self-verify mode - **THEN** the call succeeds and the parsed bundle's slots match the input keychain #### Scenario: parent-signed bundle verifies against parent's sign pubkey - **WHEN** `packPublicBundle(serverKeychain, rootAuthority)` is called and the result is verified against the root's public sign key - **THEN** the call succeeds; verification against any non-root public key fails #### Scenario: tampered slot bytes fail verification - **WHEN** any byte of a packed bundle's slot section is mutated - **THEN** verification fails #### Scenario: tampered signature fails verification - **WHEN** any byte of the trailing 64-byte signature is mutated - **THEN** verification fails #### Scenario: parse rejects malformed payloads - **WHEN** the bundle has bad magic, wrong format version, bad slotCount, a too-short payload, or extra trailing bytes - **THEN** `unpackPublicBundle` throws a parse error **Verified by:** `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"self-signed bundle round-trips and self-verifies"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"parent-signed (third-party) bundle verifies against parent's sign pubkey"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered slot bytes fail verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered signature fails verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad magic fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"wrong format version fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad slotCount fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"short payload fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"extra bytes fails parse"` --- ### Requirement: Chain bundle traversal — concatenated parent-signed links `@suaveplan/crypto` SHALL provide a `ChainBundle` wire format and a `verifyChain(bundle, trustAnchor, clock, isRevoked?)` walker. A `ChainBundle` is a header followed by a concatenation of `PublicBundle` records ordered leaf-first. The wire format identifier is `CHAIN_BUNDLE_MAGIC`, the format version is `CHAIN_BUNDLE_FORMAT_VERSION` (currently `1`), the header byte size is `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, and `linkCount` is bounded by `MAX_CHAIN_LINKS` (16). The walker MUST: verify each link's signature against the next link's `sign` slot (or against `trustAnchor` for the final link), reject expired or not-yet-valid slots via the configured `Clock`, reject revoked links via the optional `isRevoked` predicate, reject `linkCount === 0`, reject `linkCount > MAX_CHAIN_LINKS`, reject mismatched `issuerRef` between adjacent links, reject bad magic / version / length / linkCount overflows at parse time. The bundle wire format MUST be byte-identical to the v1 snapshot. #### Scenario: 3-link chain verifies end-to-end - **WHEN** a chain bundle is built as `[subService, server, root]` (each parent-signed by the next) - **THEN** `verifyChain` succeeds; the parsed chain has length 3 #### Scenario: tampered link fails verification at that link - **WHEN** any byte of a non-leaf link is mutated - **THEN** `verifyChain` rejects #### Scenario: revoked link fails via predicate - **WHEN** the `isRevoked` predicate returns true for an intermediate link - **THEN** `verifyChain` rejects #### Scenario: expired sign slot fails - **WHEN** any link's `sign` slot has `notAfter < clock.now()` - **THEN** `verifyChain` rejects with `KEYCHAIN.EXPIRED` #### Scenario: mismatched issuer coord fails - **WHEN** an intermediate link's `issuerRef` does not equal the next link's signing-slot coordinate - **THEN** `verifyChain` rejects #### Scenario: link count bounds enforced - **WHEN** packing requests `0` links or more than `MAX_CHAIN_LINKS` - **THEN** packing throws #### Scenario: parse rejects malformed payloads - **WHEN** the bundle has bad magic, bad version, out-of-range linkCount, length mismatch, or is too short - **THEN** `parseChainBundle` throws #### Scenario: wire format is byte-identical to v1 snapshot - **WHEN** a fixed-input chain is packed and compared against the committed v0x0001 snapshot - **THEN** the bytes match exactly **Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packs and parses a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifies a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects tampered link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects revoked link via predicate"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects expired sign slot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects mismatched issuer coord"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects 0-link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects > MAX_CHAIN_LINKS"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects out-of-range linkCount"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects length mismatch"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"` --- ### Requirement: Wire-format schemas exposed and committed `@suaveplan/crypto` SHALL expose a `wire-format` barrel (`@suaveplan/crypto/wire-format`) that re-exports every committed wire-format identifier and byte-size constant from its owning module: - `PUBLIC_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `PUBLIC_BUNDLE_HEADER_BYTE_SIZE` (from `bundles/public-bundle/`) - `CHAIN_BUNDLE_MAGIC`, `CHAIN_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, `MAX_CHAIN_LINKS` (from `bundles/chain-bundle/`) - `V1_COORDINATE_BYTE_SIZE`, `COORDINATE_HEADER_BYTE_SIZE` (from `derivation/coordinate/`) - `KEY_SLOT_META_PRIVATE_SCHEMA`, `KEY_SLOT_META_PUBLIC_SCHEMA`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE` (from `keychain/key-slot/`) Each schema's byte-size is committed as a constant: `KEY_SLOT_PRIVATE_BYTE_SIZE === 17 + 80 === 97` and `KEY_SLOT_PUBLIC_BYTE_SIZE === 17 + 48 === 65`. Any field-width change MUST update both the constant and its consumer test, providing a loud signal for wire-format regression. #### Scenario: barrel re-exports every committed identifier - **WHEN** a consumer imports from `@suaveplan/crypto/wire-format` - **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_FORMAT_VERSION`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `MAX_CHAIN_LINKS` are all importable #### Scenario: key-slot byte sizes are exactly the committed constants - **WHEN** the key-slot byte-size test runs - **THEN** the private slot is 97 bytes and the public slot is 65 bytes **Verified by:** `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"private slot is 17 + 80 = 97 bytes"` AND `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"public slot is 17 + 48 = 65 bytes"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` --- ### Requirement: Authority — signing abstraction over hierarchical identity `@suaveplan/crypto` SHALL define an `Authority` interface that signs payloads with a named slot under a domain tag, exposing `sign(domain, slotPurpose, payload, signal?): Promise` and `dispose(): void`. The interface is the only signing surface exposed to callers outside the package; raw `secretKey: Uint8Array` references are not passed across package boundaries. V1 ships an `InMemoryAuthority` backend that holds an unlocked `Keychain` and signs from its slots. The interface is shaped so future backends (`YubiKeyAuthority`, `TpmAuthority`, `ThresholdAuthority`) can be substituted without changes to callers. `Authority` MUST: produce signatures that verify under the corresponding public sign slot, fail under a different domain tag, throw `KEYCHAIN.UNKNOWN_PURPOSE` for unknown slots, throw `KEYCHAIN.SLOT_INVALID` when signing with a non-sign slot, throw `KEYCHAIN.EXPIRED` when the slot's `notAfter` is past, throw `AUTHORITY.REVOKED` when an `isRevoked` predicate matches, throw `AUTHORITY.DISPOSED` after `dispose()`, throw `AUTHORITY.ABORTED` when invoked with an already-aborted signal. `dispose()` MUST zeroise held secret material and MUST be idempotent. #### Scenario: sign and verify under matching slot and domain - **WHEN** `authority.sign(domain, "sign", payload)` is called - **THEN** the returned signature verifies against the public sign slot's key under the same domain #### Scenario: signature does not verify under a different domain tag - **WHEN** the signature is verified under a different domain tag - **THEN** verification returns false #### Scenario: unknown slot purpose rejected - **WHEN** `authority.sign(domain, "bogus", payload)` is called - **THEN** the call throws `KEYCHAIN.UNKNOWN_PURPOSE` #### Scenario: signing with a non-signing slot rejected - **WHEN** `authority.sign(domain, "encr", payload)` is called - **THEN** the call throws `KEYCHAIN.SLOT_INVALID` #### Scenario: dispose zeroises and locks - **WHEN** `authority.dispose()` is called and `authority.sign(...)` is then invoked - **THEN** post-dispose secret-key buffers contain zeros and the call throws `AUTHORITY.DISPOSED`; calling `dispose()` again is a no-op #### Scenario: aborted signal cancels signing - **WHEN** `authority.sign(..., signal)` is called with an already-aborted signal - **THEN** the call throws `AUTHORITY.ABORTED` #### Scenario: revoked slot fails before signing - **WHEN** an `isRevoked` predicate returns true for the slot and `authority.sign(...)` is called - **THEN** the call throws `AUTHORITY.REVOKED` before any signing primitive executes **Verified by:** `packages/crypto/crypto/src/authority/authority.test.ts::"signs with the sign slot and verifies"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"signature does NOT verify under a different domain tag"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.UNKNOWN_PURPOSE for an unknown slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.SLOT_INVALID when signing with a non-sign slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.DISPOSED after dispose"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose zeroises secret-key buffers"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose is idempotent"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.ABORTED when signal already aborted"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.REVOKED when isRevoked predicate matches"` --- ### Requirement: Hierarchical identity — Root and Server `@suaveplan/crypto` SHALL provide `createRoot(options)`, `createServer(rootAuthority, options)`, `rollServer(rootAuthority, server)`, and `revokeServer(rootAuthority, server, reason)`. A `RootIdentity` is a Keychain whose `sign` slot is the trust anchor for everything beneath it. A `ServerIdentity` is a Keychain whose public bundle has been signed by the root's sign slot under `DOMAIN_PUBLIC_KEYCHAIN_V1`. `rollServer` MUST produce a new `ServerIdentity` whose version is one greater than the prior, emit a record of the prior version (used by `RevocationList`), and accept missing optional `rng`. `revokeServer` MUST return a revocation entry without producing a new identity. `createRoot` and `createServer` MUST honour optional `notBefore`, `notAfter`, and `masterKey`. `createRoot` and `createServer` MUST accept missing optional `rng`. Nested servers (depth ≥ 2) MUST be traversable via the chain-bundle walker. #### Scenario: createRoot produces a self-verifying identity - **WHEN** `createRoot({...})` is called - **THEN** the result's public bundle verifies in self-verify mode #### Scenario: createServer is signed under root - **WHEN** `createServer(rootAuthority, ...)` is called - **THEN** the server's public bundle verifies against the root's public sign key #### Scenario: rollServer increments version and emits prior - **WHEN** `rollServer(rootAuthority, server@N)` is called - **THEN** the result is `server@(N+1)` and a `priorVersion === N` record is emitted #### Scenario: revokeServer returns an entry without re-issuing - **WHEN** `revokeServer(rootAuthority, server, reason)` is called - **THEN** the call returns a revocation entry and no new server identity #### Scenario: optional notBefore, notAfter, masterKey honoured - **WHEN** `createRoot` or `createServer` is called with explicit `notBefore` / `notAfter` / `masterKey` - **THEN** the resulting identity's slots reflect those inputs #### Scenario: optional rng accepted - **WHEN** `createRoot`, `createServer`, or `rollServer` is called without a custom `rng` - **THEN** the call succeeds #### Scenario: nested server depth-2 builds a 3-link chain - **WHEN** a sub-service is issued under a server that was issued under a root - **THEN** a 3-link chain bundle is constructible and verifies against the root anchor **Verified by:** `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot produces a self-verifying root identity"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer is signed under root"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer increments version + emits priorVersion"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"revokeServer returns an entry without re-issuing"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot honours optional notBefore/notAfter and masterKey"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer honours optional notBefore/notAfter"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer accepts no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot/createServer accept no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"nested server (depth 2) builds a 3-link chain"` --- ### Requirement: RevocationList — root-signed CRL `@suaveplan/crypto` SHALL provide a `RevocationList` type representing an append-only list of revocation entries, each entry signed by the root's sign slot under `DOMAIN_CRL_V1`. Entries are immutable: any past entry's bytes are part of the signed payload; mutation invalidates the list signature. The list exposes `append(entry, rootAuthority)`, `verify(rootPublicKey)`, and `check(ref): boolean`. `check` MUST report true for revoked refs and false otherwise. #### Scenario: append then verify succeeds - **WHEN** an entry is appended with `rootAuthority` and `verify(rootPublicKey)` is called - **THEN** verification returns true #### Scenario: mutating a past entry invalidates the list - **WHEN** any byte of any past entry is mutated and `verify(rootPublicKey)` is called - **THEN** verification returns false #### Scenario: check reports per-entry correctly - **WHEN** a server identity at a given version is appended and `check` is queried for that ref vs a different version - **THEN** `check` returns true for the appended ref and false for the other **Verified by:** `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"append then verify succeeds"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"mutating a past entry invalidates the list"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"check returns true for revoked refs and false otherwise"` --- ### Requirement: AuditLog — append-only Merkle log `@suaveplan/crypto` SHALL provide an `AuditLog` type recording every issue / roll / revoke event, structured as an append-only Merkle tree. The current Merkle root is signed by the root's sign slot under `DOMAIN_AUDIT_V1`. Any modification to a past entry changes the Merkle root and invalidates the signature. The log exposes `append(event, rootAuthority)`, `signRoot(rootAuthority)`, `verify(rootPublicKey)`, `proveInclusion(index)`, and `verifyInclusion(index, proof, expectedRoot)`. The Merkle construction MUST handle odd leaf counts by duplicating the last leaf at each level. `signRoot` over an empty log MUST produce a documented zero root. `eventCount` MUST track appends. #### Scenario: append then verify succeeds - **WHEN** an event is appended and the root signature is verified against the root public key - **THEN** verification returns true #### Scenario: tampering with a past entry invalidates the root - **WHEN** any byte of any past event is mutated - **THEN** signature verification returns false #### Scenario: inclusion proof verifies - **WHEN** an event is appended and `proveInclusion` is requested for it - **THEN** the proof verifies under the signed root #### Scenario: inclusion proof for out-of-range index throws - **WHEN** `proveInclusion(index)` is called with an out-of-range index - **THEN** the call throws #### Scenario: verifyInclusion rejects wrong / mis-sized expected roots - **WHEN** `verifyInclusion(index, proof, expectedRoot)` is called with an expected root that is wrong or of mismatched length - **THEN** the call returns false #### Scenario: odd leaf count handled - **WHEN** the log has an odd number of leaves and the last leaf is duplicated for Merkle construction - **THEN** inclusion proofs still verify #### Scenario: empty log signRoot produces zero root - **WHEN** `signRoot` is called on an empty log - **THEN** the produced root is the documented zero value #### Scenario: verify fails with a wrong sign slot - **WHEN** the log is verified against an unrelated public sign key - **THEN** verification returns false #### Scenario: eventCount tracks appends - **WHEN** `n` events are appended - **THEN** `eventCount === n` **Verified by:** `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"append then root signature verifies"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"tampering with a past entry invalidates the root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof verifies for appended entries"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof for out-of-range index throws"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for a wrong expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for length-mismatched expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof handles odd leaf count (last leaf duplicated)"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"signRoot on empty log produces a zero root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verify fails with a wrong sign slot"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"eventCount tracks appends"` --- ### Requirement: Packer — encrypt-and-sign envelope `@suaveplan/crypto` SHALL provide a `Packer` / `Unpacker` pair implementing a schema-driven encrypt-and-sign envelope. The envelope MUST: round-trip a value byte-for-byte under matching parties / nonce / domain, reject envelopes packed under a different domain (cross-protocol replay defence), reject envelopes whose `issuedAt` is older than a configurable replay window, reject envelopes whose `nonce` has been seen within a configurable nonce-cache window, validate pack-side nonce length, reject envelopes whose stated ciphertext length overruns the buffer, reject too-short envelopes. #### Scenario: round-trip preserves the value - **WHEN** `packer.pack(payload, ...)` is called and the result is `unpacker.unpack(envelope, ...)`-ed - **THEN** the returned payload byte-equals the original #### Scenario: replay outside window rejected - **WHEN** an envelope older than the replay window is presented - **THEN** `unpacker.unpack` throws #### Scenario: nonce dedup rejects replay - **WHEN** the same envelope is presented twice in succession - **THEN** the first call succeeds; the second throws #### Scenario: cross-protocol domain tag rejected - **WHEN** an envelope packed under one domain is presented to an `Unpacker` configured for another - **THEN** `unpacker.unpack` throws #### Scenario: invalid pack-side nonce length rejected - **WHEN** `packer.pack` is called with a nonce of the wrong length - **THEN** the call throws #### Scenario: malformed envelope rejected - **WHEN** an envelope's stated ciphertext length overruns the buffer, OR the envelope is too short - **THEN** parsing throws **Verified by:** `packages/crypto/crypto/src/packer/packer.test.ts::"round-trip preserves the value"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"replay outside window rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"nonce dedup rejects replay"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects invalid pack-side nonce length"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects envelope where stated ciphertext length overruns buffer"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects too-short envelope"` --- ### Requirement: ChunkedPacker — per-chunk-signed P2P fragmentation `@suaveplan/crypto` SHALL provide `ChunkedPacker` / `ChunkedUnpacker` implementing per-chunk signed fragmentation under `DOMAIN_CHUNK_V1`. Reassembly MUST: byte-equal the original payload on round-trip, reject tampered chunks at reassembly, reject missing chunks, reassemble correctly when chunks arrive out of order, reject malformed chunks (too short, wrong `dataLen`, mismatched total), reject empty chunk lists, reject invalid chunk sizes. #### Scenario: round-trip reassembles byte-identical - **WHEN** a payload is fragmented into N chunks and reassembled - **THEN** the reassembled bytes byte-equal the original #### Scenario: tampered chunk rejected at reassembly - **WHEN** any byte of a chunk is mutated - **THEN** reassembly throws #### Scenario: missing chunk rejected - **WHEN** any chunk is omitted - **THEN** reassembly throws #### Scenario: out-of-order chunks reassemble - **WHEN** chunks are presented in non-sequential order - **THEN** reassembly succeeds and byte-equals the original #### Scenario: malformed chunks rejected - **WHEN** a chunk is too short, has the wrong `dataLen` field, or has a mismatched total - **THEN** reassembly throws #### Scenario: empty list and invalid sizes rejected - **WHEN** an empty chunk list is presented, or chunking is requested with an invalid size - **THEN** the call throws **Verified by:** `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"round-trip reassembles byte-identical"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"tampered chunk rejected at reassembly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"missing chunk rejected"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"out-of-order chunks reassemble correctly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects malformed chunk (too short)"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunk with wrong dataLen field"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunks with mismatched total"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects empty chunks list"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects invalid chunk size"` ### Requirement: Trust-anchor bootstrap `@suaveplan/crypto` SHALL ship a trust-anchor loader at `src/trust-anchors/` that reads a self-verifying root public bundle from a caller-supplied filesystem reader and returns both the raw bytes (for direct use with `verifyChain`) and the parsed `PublicBundle` (for diagnostics). The loader SHALL self-verify the bundle in root-mode (`verifyPublicBundle(bytes, null)`) on every load — bytes that do not self-verify SHALL be rejected with `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED`. The package SHALL expose: - `loadTrustAnchor(reader, path): Promise` — explicit-path entry. - `loadTrustAnchorFromEnv(reader, options): Promise` — env-driven entry. `options.getenv` is required (the universal package never reads `process.env` directly); production wires `(name) => process.env[name]`, tests inject deterministic stubs. `options.envVar` defaults to `SUAVEPLAN_TRUST_ANCHOR_PATH`. - `DEFAULT_TRUST_ANCHOR_ENV_VAR` — the canonical env-var name as a `const`. - Types `TrustAnchor`, `TrustAnchorReader`, `TrustAnchorEnvGetter`, `LoadTrustAnchorFromEnvOptions`. The universal-tier package MUST NOT import `node:fs` or read `process.env` at the module-import level; all platform access is caller-injected via the `TrustAnchorReader` interface and the optional `getenv` callback. Production wires `(path) => fs.readFile(path)`; tests inject deterministic stubs. #### Scenario: round-trip self-verify - **WHEN** a self-signed root public bundle is supplied via the reader and `loadTrustAnchor(reader, path)` is called - **THEN** the call returns `{ bytes, bundle }` where `bytes` is byte-identical to the input and `bundle` has a 32-byte `sign` slot public key #### Scenario: env-var default name - **WHEN** `loadTrustAnchorFromEnv(reader, { getenv })` is called without `options.envVar` - **THEN** `getenv` is invoked with `"SUAVEPLAN_TRUST_ANCHOR_PATH"` and the returned path is read #### Scenario: env-var name override - **WHEN** `loadTrustAnchorFromEnv(reader, { envVar: "VAULT_ANCHOR", getenv })` is called - **THEN** `getenv` is invoked with `"VAULT_ANCHOR"` and the returned path is read #### Scenario: missing env-var rejected - **WHEN** `loadTrustAnchorFromEnv(reader, { envVar, getenv: () => undefined })` is called - **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.PATH_MISSING` and `context.envVar` reflects the requested name #### Scenario: empty env-var string rejected - **WHEN** `loadTrustAnchorFromEnv(reader, { envVar, getenv: () => "" })` is called - **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.PATH_MISSING` #### Scenario: reader failure surfaces structured error - **WHEN** the reader's `read(path)` rejects (e.g. ENOENT) - **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.READ_FAILED`, with the reader's error attached via `cause` and `context.path` equal to the requested path #### Scenario: tampered bytes rejected - **WHEN** the loader receives bytes that parse but fail `verifyPublicBundle(bytes, null)` - **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED` and `context.byteLength` reflects the actual length #### Scenario: garbage bytes rejected - **WHEN** the loader receives bytes that fail to parse as a public bundle - **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED` **Verified by:** `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"loads, self-verifies, and returns byte-identical bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"the returned bundle parses identically to a fresh verify on the bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"uses the default env var name SUAVEPLAN_TRUST_ANCHOR_PATH"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"honors a caller-supplied envVar override"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"getenv is invoked exactly once with the resolved env-var name"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.PATH_MISSING when env var is unset"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.PATH_MISSING when env var is empty string"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.READ_FAILED when reader rejects"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.SELF_VERIFY_FAILED on tampered bundle bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.SELF_VERIFY_FAILED on garbage bytes"`