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 inpackages/crypto/crypto/src/before this change archives. (Seeopenspec/AGENTS.mdSection 13 and the parent change'stasks.mdstep 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—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"