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
falsewithout 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-emptyUint8Array - THEN every element of
xis0afterwards
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 withmemoryCost < 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
aeadDecryptis 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 withkey.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
aeadEncryptthrowsCryptoError("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 forcrypto.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
aeadDecryptfails - THEN the thrown
CryptoError'scontextcontains 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/cryptoruns - 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 underaead/,agree/,auth-box/,sealed-box/,sign/,primitives/, orengines/ - 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.tsis diffed against the pre-change snapshot attests/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,validateKeypairfrom@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,mnemonicToSeedfrom@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
LegacyVaultfrom@suaveplan/crypto/vault/legacy-vault - THEN it resolves and behaves identically to the pre-fold
crypto-vaultVaultclass (renamed for collision avoidance)
Scenario: ChainSigner is gone
- WHEN a consumer attempts to import
ChainSignerfrom 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,revokeServerfrom@suaveplan/crypto - THEN all four resolve and produce typed
RootIdentity/ServerIdentity/RolledServer/RevokedServervalues
Scenario: encrypt-and-sign envelopes round-trip with replay protection
- WHEN
Packer.packthenUnpacker.unpackruns 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.packproduces N chunks and one chunk's data byte is flipped - THEN
ChunkedUnpacker.reassemblethrows a coded error citing the tampered chunk index
Scenario: revocation list verifies under root authority
- WHEN a
RevocationListis 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
AuditLogis signed and any past event is then mutated - THEN subsequent
verifythrowsCryptoError(AUTHORITY.REVOKED)withaudit.verify.root_mismatch
Scenario: three-regime vault primitives round-trip
- WHEN
sealPrivateKeyBlobthenopenPrivateKeyBlobruns with the same password - THEN the bytes round-trip;
changePasswordrotates 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_VERSIONconstants 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
--passwordor--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(orserverRevokeCmd) is invoked twice with the sameidempotencyKeyand 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.tsruns - 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
aeadfrom@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-utilsremain)
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
aesGcmStreamingEngineexported 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
xchacha20StreamingEngineexported from this package, byte-for-byte
Scenario: unknown algorithm rejected
- WHEN
encryptStreamAead({ algorithm: "rot13" as never, ... })is called - THEN it throws
SuaveplanErrorwith codeCRYPTO.STREAM_AEAD_HEADER_INVALIDandcontext.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 viacollectStream - THEN the collected bytes equal the original blob bytes
Scenario: legacy aliases preserved
- WHEN
streamEncrypt,streamDecrypt,ChaCha20Stream,AESStreamare 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)producesblobanddecryptWithPassword("correct horse battery staple", blob)is called - THEN the result equals
plaintextbyte-for-byte
Scenario: round-trip with scrypt KDF
- WHEN
encryptWithPassword(pw, plaintext, { kdf: "scrypt" })producesblobanddecryptWithPassword(pw, blob)is called - THEN the result equals
plaintextbyte-for-byte AND the blob's KDF byte signals scrypt
Scenario: blob header self-describes
- WHEN the same
(password, plaintext)is encrypted twice with differentkdfParams - THEN the two blobs have different KDF-params bytes in their headers AND both decrypt successfully without the caller passing
kdfParamstodecryptWithPassword
Scenario: unknown KDF rejected
- WHEN
encryptWithPassword(pw, plaintext, { kdf: "bcrypt" as never })is called - THEN it throws
SuaveplanErrorwith codeCRYPTO.PASSWORD_KDF_UNKNOWNandcontext.kdf === "bcrypt"
Scenario: corrupt header rejected
- WHEN
decryptWithPassword(pw, blob)is called with ablobwhose first four bytes are not"SVPP" - THEN it throws
SuaveplanErrorwith codeCRYPTO.PASSWORD_BLOB_HEADER_INVALID
Scenario: unsupported version rejected
- WHEN
decryptWithPassword(pw, blob)is called with ablobwhose version byte is99 - THEN it throws
SuaveplanErrorwith codeCRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTEDandcontext.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_SIZEis 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 matchCHAIN_BUNDLE_MAGIC - THEN the call throws
CryptoErrorwith codeCHAIN.WRONG_FORMATand messagecrypto.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 notCHAIN_BUNDLE_FORMAT_VERSION - THEN the call throws
CryptoErrorwith codeCHAIN.WRONG_FORMATand messagecrypto.chain-bundle.unsupported-versionandcontext.versioncarrying 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 thanCHAIN_BUNDLE_HEADER_BYTE_SIZE - THEN the call throws
CryptoErrorwith codeCHAIN.WRONG_FORMATand messagecrypto.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—u8selecting whichDerivationLayoutinterprets 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
generateKeychainis 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_V1allocates 32 bits touserId, 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
generateKeychainis called with a per-purpose algorithm override map - THEN the resulting slot's
algorithmfield 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
publicKeyper slot; everysecretKeyfield isundefinedand 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_V1schema'sbyteSizeis 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 whosebyteSize === 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 whosebyteSize > 8 - THEN the call throws
CryptoErrorcodeKEYCHAIN.LAYOUT_TOO_WIDE
Scenario: undersized custom layout is rejected
- WHEN
defineDerivationLayout(id, undersizedSchema)is called with a schema whosebyteSize < 8 - THEN the call throws
CryptoErrorcodeKEYCHAIN.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
CryptoErrorcodeKEYCHAIN.LAYOUT_ALREADY_REGISTERED
Scenario: parsing an unknown layout id fails distinctly
- WHEN a wire-format payload's
layoutIdis not registered - THEN parsing throws
CryptoErrorcodeKEYCHAIN.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
Authorityis asked to sign with a slot whosenotAfteris beforeclock.now() - THEN the call throws
CryptoErrorcodeKEYCHAIN.EXPIRED
Scenario: verifying against a not-yet-valid slot fails
- WHEN a verify path is called against a public keychain slot whose
notBeforeis afterclock.now() - THEN the call throws
CryptoErrorcodeKEYCHAIN.NOT_YET_VALID
Scenario: clock exactly at notBefore or notAfter passes
- WHEN a verify call observes
clock.now() === notBeforeorclock.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: trueagainst a slot whosenotAfteris past - THEN the call succeeds; the same call against a
NOT_YET_VALIDslot 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_TAGSis 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
0x00byte
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_V1is presented to anUnpackerconfigured forDOMAIN_PACKER_V1 - THEN the call throws a
CryptoErrorwhose 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
unpackPublicBundlethrows 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
verifyChainsucceeds; 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
verifyChainrejects
Scenario: revoked link fails via predicate
- WHEN the
isRevokedpredicate returns true for an intermediate link - THEN
verifyChainrejects
Scenario: expired sign slot fails
- WHEN any link's
signslot hasnotAfter < clock.now() - THEN
verifyChainrejects withKEYCHAIN.EXPIRED
Scenario: mismatched issuer coord fails
- WHEN an intermediate link's
issuerRefdoes not equal the next link's signing-slot coordinate - THEN
verifyChainrejects
Scenario: link count bounds enforced
- WHEN packing requests
0links or more thanMAX_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
parseChainBundlethrows
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(frombundles/public-bundle/)CHAIN_BUNDLE_MAGIC,CHAIN_BUNDLE_FORMAT_VERSION,CHAIN_BUNDLE_HEADER_BYTE_SIZE,MAX_CHAIN_LINKS(frombundles/chain-bundle/)V1_COORDINATE_BYTE_SIZE,COORDINATE_HEADER_BYTE_SIZE(fromderivation/coordinate/)KEY_SLOT_META_PRIVATE_SCHEMA,KEY_SLOT_META_PUBLIC_SCHEMA,KEY_SLOT_PRIVATE_BYTE_SIZE,KEY_SLOT_PUBLIC_BYTE_SIZE(fromkeychain/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_LINKSare 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 andauthority.sign(...)is then invoked - THEN post-dispose secret-key buffers contain zeros and the call throws
AUTHORITY.DISPOSED; callingdispose()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
isRevokedpredicate returns true for the slot andauthority.sign(...)is called - THEN the call throws
AUTHORITY.REVOKEDbefore 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 apriorVersion === Nrecord 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
createRootorcreateServeris called with explicitnotBefore/notAfter/masterKey - THEN the resulting identity's slots reflect those inputs
Scenario: optional rng accepted
- WHEN
createRoot,createServer, orrollServeris called without a customrng - 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
rootAuthorityandverify(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
checkis queried for that ref vs a different version - THEN
checkreturns 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
proveInclusionis 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
signRootis 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
nevents 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 isunpacker.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.unpackthrows
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
Unpackerconfigured for another - THEN
unpacker.unpackthrows
Scenario: invalid pack-side nonce length rejected
- WHEN
packer.packis 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
dataLenfield, 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.getenvis required (the universal package never readsprocess.envdirectly); production wires(name) => process.env[name], tests inject deterministic stubs.options.envVardefaults toSUAVEPLAN_TRUST_ANCHOR_PATH.DEFAULT_TRUST_ANCHOR_ENV_VAR— the canonical env-var name as aconst.- 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 }wherebytesis byte-identical to the input andbundlehas a 32-bytesignslot public key
Scenario: env-var default name
- WHEN
loadTrustAnchorFromEnv(reader, { getenv })is called withoutoptions.envVar - THEN
getenvis 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
getenvis 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
CryptoErrorwith codeCRYPTO.TRUST_ANCHOR.PATH_MISSINGandcontext.envVarreflects the requested name
Scenario: empty env-var string rejected
- WHEN
loadTrustAnchorFromEnv(reader, { envVar, getenv: () => "" })is called - THEN the call throws
CryptoErrorwith codeCRYPTO.TRUST_ANCHOR.PATH_MISSING
Scenario: reader failure surfaces structured error
- WHEN the reader's
read(path)rejects (e.g. ENOENT) - THEN the call throws
CryptoErrorwith codeCRYPTO.TRUST_ANCHOR.READ_FAILED, with the reader's error attached viacauseandcontext.pathequal to the requested path
Scenario: tampered bytes rejected
- WHEN the loader receives bytes that parse but fail
verifyPublicBundle(bytes, null) - THEN the call throws
CryptoErrorwith codeCRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILEDandcontext.byteLengthreflects the actual length
Scenario: garbage bytes rejected
- WHEN the loader receives bytes that fail to parse as a public bundle
- THEN the call throws
CryptoErrorwith codeCRYPTO.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"