# 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"` --- ### Requirement: Chain bundle traversal — concatenated parent-signed links `@suaveplan/crypto` SHALL provide a `ChainBundle` wire format and a `verifyChain(bundle, trustAnchor, clock, isRevoked?)` walker. A `ChainBundle` is a header followed by a concatenation of `PublicBundle` records ordered leaf-first. The wire format identifier is `CHAIN_BUNDLE_MAGIC`, the format version is `CHAIN_BUNDLE_FORMAT_VERSION` (currently `1`), the header byte size is `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, and `linkCount` is bounded by `MAX_CHAIN_LINKS` (16). The walker MUST: verify each link's signature against the next link's `sign` slot (or against `trustAnchor` for the final link), reject expired or not-yet-valid slots via the configured `Clock`, reject revoked links via the optional `isRevoked` predicate, reject `linkCount === 0`, reject `linkCount > MAX_CHAIN_LINKS`, reject mismatched `issuerRef` between adjacent links, reject bad magic / version / length / linkCount overflows at parse time. The bundle wire format MUST be byte-identical to the v1 snapshot. #### Scenario: 3-link chain verifies end-to-end - **WHEN** a chain bundle is built as `[subService, server, root]` (each parent-signed by the next) - **THEN** `verifyChain` succeeds; the parsed chain has length 3 #### Scenario: tampered link fails verification at that link - **WHEN** any byte of a non-leaf link is mutated - **THEN** `verifyChain` rejects #### Scenario: revoked link fails via predicate - **WHEN** the `isRevoked` predicate returns true for an intermediate link - **THEN** `verifyChain` rejects #### Scenario: expired sign slot fails - **WHEN** any link's `sign` slot has `notAfter < clock.now()` - **THEN** `verifyChain` rejects with `KEYCHAIN.EXPIRED` #### Scenario: mismatched issuer coord fails - **WHEN** an intermediate link's `issuerRef` does not equal the next link's signing-slot coordinate - **THEN** `verifyChain` rejects #### Scenario: link count bounds enforced - **WHEN** packing requests `0` links or more than `MAX_CHAIN_LINKS` - **THEN** packing throws #### Scenario: parse rejects malformed payloads - **WHEN** the bundle has bad magic, bad version, out-of-range linkCount, length mismatch, or is too short - **THEN** `parseChainBundle` throws #### Scenario: wire format is byte-identical to v1 snapshot - **WHEN** a fixed-input chain is packed and compared against the committed v0x0001 snapshot - **THEN** the bytes match exactly **Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packs and parses a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifies a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects tampered link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects revoked link via predicate"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects expired sign slot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects mismatched issuer coord"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects 0-link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects > MAX_CHAIN_LINKS"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects out-of-range linkCount"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects length mismatch"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"` --- ### Requirement: Wire-format schemas exposed and committed `@suaveplan/crypto` SHALL expose a `wire-format` barrel (`@suaveplan/crypto/wire-format`) that re-exports every committed wire-format identifier and byte-size constant from its owning module: - `PUBLIC_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `PUBLIC_BUNDLE_HEADER_BYTE_SIZE` (from `bundles/public-bundle/`) - `CHAIN_BUNDLE_MAGIC`, `CHAIN_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, `MAX_CHAIN_LINKS` (from `bundles/chain-bundle/`) - `V1_COORDINATE_BYTE_SIZE`, `COORDINATE_HEADER_BYTE_SIZE` (from `derivation/coordinate/`) - `KEY_SLOT_META_PRIVATE_SCHEMA`, `KEY_SLOT_META_PUBLIC_SCHEMA`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE` (from `keychain/key-slot/`) Each schema's byte-size is committed as a constant: `KEY_SLOT_PRIVATE_BYTE_SIZE === 17 + 80 === 97` and `KEY_SLOT_PUBLIC_BYTE_SIZE === 17 + 48 === 65`. Any field-width change MUST update both the constant and its consumer test, providing a loud signal for wire-format regression. #### Scenario: barrel re-exports every committed identifier - **WHEN** a consumer imports from `@suaveplan/crypto/wire-format` - **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_FORMAT_VERSION`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `MAX_CHAIN_LINKS` are all importable #### Scenario: key-slot byte sizes are exactly the committed constants - **WHEN** the key-slot byte-size test runs - **THEN** the private slot is 97 bytes and the public slot is 65 bytes **Verified by:** `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"private slot is 17 + 80 = 97 bytes"` AND `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"public slot is 17 + 48 = 65 bytes"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` --- ### Requirement: Authority — signing abstraction over hierarchical identity `@suaveplan/crypto` SHALL define an `Authority` interface that signs payloads with a named slot under a domain tag, exposing `sign(domain, slotPurpose, payload, signal?): Promise` and `dispose(): void`. The interface is the only signing surface exposed to callers outside the package; raw `secretKey: Uint8Array` references are not passed across package boundaries. V1 ships an `InMemoryAuthority` backend that holds an unlocked `Keychain` and signs from its slots. The interface is shaped so future backends (`YubiKeyAuthority`, `TpmAuthority`, `ThresholdAuthority`) can be substituted without changes to callers. `Authority` MUST: produce signatures that verify under the corresponding public sign slot, fail under a different domain tag, throw `KEYCHAIN.UNKNOWN_PURPOSE` for unknown slots, throw `KEYCHAIN.SLOT_INVALID` when signing with a non-sign slot, throw `KEYCHAIN.EXPIRED` when the slot's `notAfter` is past, throw `AUTHORITY.REVOKED` when an `isRevoked` predicate matches, throw `AUTHORITY.DISPOSED` after `dispose()`, throw `AUTHORITY.ABORTED` when invoked with an already-aborted signal. `dispose()` MUST zeroise held secret material and MUST be idempotent. #### Scenario: sign and verify under matching slot and domain - **WHEN** `authority.sign(domain, "sign", payload)` is called - **THEN** the returned signature verifies against the public sign slot's key under the same domain #### Scenario: signature does not verify under a different domain tag - **WHEN** the signature is verified under a different domain tag - **THEN** verification returns false #### Scenario: unknown slot purpose rejected - **WHEN** `authority.sign(domain, "bogus", payload)` is called - **THEN** the call throws `KEYCHAIN.UNKNOWN_PURPOSE` #### Scenario: signing with a non-signing slot rejected - **WHEN** `authority.sign(domain, "encr", payload)` is called - **THEN** the call throws `KEYCHAIN.SLOT_INVALID` #### Scenario: dispose zeroises and locks - **WHEN** `authority.dispose()` is called and `authority.sign(...)` is then invoked - **THEN** post-dispose secret-key buffers contain zeros and the call throws `AUTHORITY.DISPOSED`; calling `dispose()` again is a no-op #### Scenario: aborted signal cancels signing - **WHEN** `authority.sign(..., signal)` is called with an already-aborted signal - **THEN** the call throws `AUTHORITY.ABORTED` #### Scenario: revoked slot fails before signing - **WHEN** an `isRevoked` predicate returns true for the slot and `authority.sign(...)` is called - **THEN** the call throws `AUTHORITY.REVOKED` before any signing primitive executes **Verified by:** `packages/crypto/crypto/src/authority/authority.test.ts::"signs with the sign slot and verifies"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"signature does NOT verify under a different domain tag"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.UNKNOWN_PURPOSE for an unknown slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.SLOT_INVALID when signing with a non-sign slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.DISPOSED after dispose"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose zeroises secret-key buffers"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose is idempotent"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.ABORTED when signal already aborted"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.REVOKED when isRevoked predicate matches"` --- ### Requirement: Hierarchical identity — Root and Server `@suaveplan/crypto` SHALL provide `createRoot(options)`, `createServer(rootAuthority, options)`, `rollServer(rootAuthority, server)`, and `revokeServer(rootAuthority, server, reason)`. A `RootIdentity` is a Keychain whose `sign` slot is the trust anchor for everything beneath it. A `ServerIdentity` is a Keychain whose public bundle has been signed by the root's sign slot under `DOMAIN_PUBLIC_KEYCHAIN_V1`. `rollServer` MUST produce a new `ServerIdentity` whose version is one greater than the prior, emit a record of the prior version (used by `RevocationList`), and accept missing optional `rng`. `revokeServer` MUST return a revocation entry without producing a new identity. `createRoot` and `createServer` MUST honour optional `notBefore`, `notAfter`, and `masterKey`. `createRoot` and `createServer` MUST accept missing optional `rng`. Nested servers (depth ≥ 2) MUST be traversable via the chain-bundle walker. #### Scenario: createRoot produces a self-verifying identity - **WHEN** `createRoot({...})` is called - **THEN** the result's public bundle verifies in self-verify mode #### Scenario: createServer is signed under root - **WHEN** `createServer(rootAuthority, ...)` is called - **THEN** the server's public bundle verifies against the root's public sign key #### Scenario: rollServer increments version and emits prior - **WHEN** `rollServer(rootAuthority, server@N)` is called - **THEN** the result is `server@(N+1)` and a `priorVersion === N` record is emitted #### Scenario: revokeServer returns an entry without re-issuing - **WHEN** `revokeServer(rootAuthority, server, reason)` is called - **THEN** the call returns a revocation entry and no new server identity #### Scenario: optional notBefore, notAfter, masterKey honoured - **WHEN** `createRoot` or `createServer` is called with explicit `notBefore` / `notAfter` / `masterKey` - **THEN** the resulting identity's slots reflect those inputs #### Scenario: optional rng accepted - **WHEN** `createRoot`, `createServer`, or `rollServer` is called without a custom `rng` - **THEN** the call succeeds #### Scenario: nested server depth-2 builds a 3-link chain - **WHEN** a sub-service is issued under a server that was issued under a root - **THEN** a 3-link chain bundle is constructible and verifies against the root anchor **Verified by:** `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot produces a self-verifying root identity"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer is signed under root"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer increments version + emits priorVersion"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"revokeServer returns an entry without re-issuing"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot honours optional notBefore/notAfter and masterKey"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer honours optional notBefore/notAfter"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer accepts no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot/createServer accept no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"nested server (depth 2) builds a 3-link chain"` --- ### Requirement: RevocationList — root-signed CRL `@suaveplan/crypto` SHALL provide a `RevocationList` type representing an append-only list of revocation entries, each entry signed by the root's sign slot under `DOMAIN_CRL_V1`. Entries are immutable: any past entry's bytes are part of the signed payload; mutation invalidates the list signature. The list exposes `append(entry, rootAuthority)`, `verify(rootPublicKey)`, and `check(ref): boolean`. `check` MUST report true for revoked refs and false otherwise. #### Scenario: append then verify succeeds - **WHEN** an entry is appended with `rootAuthority` and `verify(rootPublicKey)` is called - **THEN** verification returns true #### Scenario: mutating a past entry invalidates the list - **WHEN** any byte of any past entry is mutated and `verify(rootPublicKey)` is called - **THEN** verification returns false #### Scenario: check reports per-entry correctly - **WHEN** a server identity at a given version is appended and `check` is queried for that ref vs a different version - **THEN** `check` returns true for the appended ref and false for the other **Verified by:** `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"append then verify succeeds"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"mutating a past entry invalidates the list"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"check returns true for revoked refs and false otherwise"` --- ### Requirement: AuditLog — append-only Merkle log `@suaveplan/crypto` SHALL provide an `AuditLog` type recording every issue / roll / revoke event, structured as an append-only Merkle tree. The current Merkle root is signed by the root's sign slot under `DOMAIN_AUDIT_V1`. Any modification to a past entry changes the Merkle root and invalidates the signature. The log exposes `append(event, rootAuthority)`, `signRoot(rootAuthority)`, `verify(rootPublicKey)`, `proveInclusion(index)`, and `verifyInclusion(index, proof, expectedRoot)`. The Merkle construction MUST handle odd leaf counts by duplicating the last leaf at each level. `signRoot` over an empty log MUST produce a documented zero root. `eventCount` MUST track appends. #### Scenario: append then verify succeeds - **WHEN** an event is appended and the root signature is verified against the root public key - **THEN** verification returns true #### Scenario: tampering with a past entry invalidates the root - **WHEN** any byte of any past event is mutated - **THEN** signature verification returns false #### Scenario: inclusion proof verifies - **WHEN** an event is appended and `proveInclusion` is requested for it - **THEN** the proof verifies under the signed root #### Scenario: inclusion proof for out-of-range index throws - **WHEN** `proveInclusion(index)` is called with an out-of-range index - **THEN** the call throws #### Scenario: verifyInclusion rejects wrong / mis-sized expected roots - **WHEN** `verifyInclusion(index, proof, expectedRoot)` is called with an expected root that is wrong or of mismatched length - **THEN** the call returns false #### Scenario: odd leaf count handled - **WHEN** the log has an odd number of leaves and the last leaf is duplicated for Merkle construction - **THEN** inclusion proofs still verify #### Scenario: empty log signRoot produces zero root - **WHEN** `signRoot` is called on an empty log - **THEN** the produced root is the documented zero value #### Scenario: verify fails with a wrong sign slot - **WHEN** the log is verified against an unrelated public sign key - **THEN** verification returns false #### Scenario: eventCount tracks appends - **WHEN** `n` events are appended - **THEN** `eventCount === n` **Verified by:** `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"append then root signature verifies"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"tampering with a past entry invalidates the root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof verifies for appended entries"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof for out-of-range index throws"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for a wrong expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for length-mismatched expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof handles odd leaf count (last leaf duplicated)"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"signRoot on empty log produces a zero root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verify fails with a wrong sign slot"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"eventCount tracks appends"` --- ### Requirement: Packer — encrypt-and-sign envelope `@suaveplan/crypto` SHALL provide a `Packer` / `Unpacker` pair implementing a schema-driven encrypt-and-sign envelope. The envelope MUST: round-trip a value byte-for-byte under matching parties / nonce / domain, reject envelopes packed under a different domain (cross-protocol replay defence), reject envelopes whose `issuedAt` is older than a configurable replay window, reject envelopes whose `nonce` has been seen within a configurable nonce-cache window, validate pack-side nonce length, reject envelopes whose stated ciphertext length overruns the buffer, reject too-short envelopes. #### Scenario: round-trip preserves the value - **WHEN** `packer.pack(payload, ...)` is called and the result is `unpacker.unpack(envelope, ...)`-ed - **THEN** the returned payload byte-equals the original #### Scenario: replay outside window rejected - **WHEN** an envelope older than the replay window is presented - **THEN** `unpacker.unpack` throws #### Scenario: nonce dedup rejects replay - **WHEN** the same envelope is presented twice in succession - **THEN** the first call succeeds; the second throws #### Scenario: cross-protocol domain tag rejected - **WHEN** an envelope packed under one domain is presented to an `Unpacker` configured for another - **THEN** `unpacker.unpack` throws #### Scenario: invalid pack-side nonce length rejected - **WHEN** `packer.pack` is called with a nonce of the wrong length - **THEN** the call throws #### Scenario: malformed envelope rejected - **WHEN** an envelope's stated ciphertext length overruns the buffer, OR the envelope is too short - **THEN** parsing throws **Verified by:** `packages/crypto/crypto/src/packer/packer.test.ts::"round-trip preserves the value"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"replay outside window rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"nonce dedup rejects replay"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects invalid pack-side nonce length"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects envelope where stated ciphertext length overruns buffer"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects too-short envelope"` --- ### Requirement: ChunkedPacker — per-chunk-signed P2P fragmentation `@suaveplan/crypto` SHALL provide `ChunkedPacker` / `ChunkedUnpacker` implementing per-chunk signed fragmentation under `DOMAIN_CHUNK_V1`. Reassembly MUST: byte-equal the original payload on round-trip, reject tampered chunks at reassembly, reject missing chunks, reassemble correctly when chunks arrive out of order, reject malformed chunks (too short, wrong `dataLen`, mismatched total), reject empty chunk lists, reject invalid chunk sizes. #### Scenario: round-trip reassembles byte-identical - **WHEN** a payload is fragmented into N chunks and reassembled - **THEN** the reassembled bytes byte-equal the original #### Scenario: tampered chunk rejected at reassembly - **WHEN** any byte of a chunk is mutated - **THEN** reassembly throws #### Scenario: missing chunk rejected - **WHEN** any chunk is omitted - **THEN** reassembly throws #### Scenario: out-of-order chunks reassemble - **WHEN** chunks are presented in non-sequential order - **THEN** reassembly succeeds and byte-equals the original #### Scenario: malformed chunks rejected - **WHEN** a chunk is too short, has the wrong `dataLen` field, or has a mismatched total - **THEN** reassembly throws #### Scenario: empty list and invalid sizes rejected - **WHEN** an empty chunk list is presented, or chunking is requested with an invalid size - **THEN** the call throws **Verified by:** `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"round-trip reassembles byte-identical"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"tampered chunk rejected at reassembly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"missing chunk rejected"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"out-of-order chunks reassemble correctly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects malformed chunk (too short)"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunk with wrong dataLen field"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunks with mismatched total"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects empty chunks list"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects invalid chunk size"`