Files
suaveplan/openspec/changes/archive/2026-05-03-crypto-spec-restore-versioning/specs/crypto/spec.md
2026-05-27 23:29:53 +01:00

38 KiB

Capability Spec Delta: crypto (crypto-spec-restore-versioning)

Package: @suaveplan/crypto Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. Each pointer is verified against an existing test in packages/crypto/crypto/src/ before this change archives. (See openspec/AGENTS.md Section 13 and the parent change's tasks.md step 4.)

ADDED Requirements

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"