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

68 KiB

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<Uint8Array, Uint8Array> and createDecryptionStream(opts) → TransformStream<Uint8Array, Uint8Array> from aead/streaming/web-streams.ts. These SHALL be drop-in pipeThrough targets for any Web-Streams ReadableStream<Uint8Array> source (e.g. Blob.stream(), Response.body, fetch().body). The package SHALL also expose collectStream(stream) → Promise<Uint8Array> (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<string, number> (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"


@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.

  • 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
  • WHEN any byte of a non-leaf link is mutated
  • THEN verifyChain rejects
  • 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
  • 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<Uint8Array> 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
  • 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<TrustAnchor> — explicit-path entry.
  • loadTrustAnchorFromEnv(reader, options): Promise<TrustAnchor> — 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"