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

47 KiB
Raw Blame History

Capability Spec Delta: crypto (restore-crypto-keychain-packer)

Package: @suaveplan/crypto Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: Eight-slot Keychain with full-width derivation coordinates

The package 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 ").

The slot registry is open: additional purposes can be registered via registerPurpose(tag) without a wire-format break, because purpose is an ASCII tag in libsodium's 8-byte ctx argument, not a closed ordinal. The eight defaults ship as a baseline registry; consumers MAY register additional purposes for their applications.

A DerivationCoordinate carries:

  • purpose — 4-byte ASCII (the slot type)
  • application — 4-byte ASCII (which product/component, e.g. "chat", "vlt ", "web ")
  • 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 directly onto libsodium's 8-byte ctx argument; the four layout fields pack into libsodium's subkey_id (u64). Together they form the 16-byte derivation namespace this package consumes via sodium.crypto_kdf_derive_from_key(...).

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: generate a complete keychain with default algorithms

  • WHEN a caller invokes generateKeychain({ application: "chat", userId: 123_456, layoutId: 0, masterVersion: 1, depth: 1, slotVersion: 1 })
  • THEN the result has all eight default-purpose slots populated with default-algorithm keypairs, each slot's DerivationCoordinate carries application === "chat", userId === 123_456, masterVersion === 1, depth === 1, slotVersion === 1, and notBefore <= clock.now() <= notAfter

Scenario: userId beyond the original 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: registering a custom purpose tag is non-breaking

  • WHEN a caller invokes registerPurpose("audi") and then generateKeychainSlot({ purpose: "audi", ... })
  • THEN the slot is generated normally; existing pre-registration keychains remain parseable; the purpose byte tag appears verbatim in the wire format

Scenario: missing algorithm tag is rejected at parse time

  • WHEN parseKeychain(bytes) is called on a wire-format payload missing the algorithm field on any slot
  • THEN the call throws CryptoError with code KEYCHAIN.MISSING_ALGORITHM, naming the offending slot

Scenario: unknown algorithm tag is rejected

  • WHEN parseKeychain(bytes) is called with a slot whose algorithm field is not on the v1 allowlist
  • THEN the call throws CryptoError with code KEYCHAIN.UNKNOWN_ALGORITHM, naming the offending slot and tag

Scenario: extract public-only keychain

  • WHEN extractPublicKeys(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 structurally distinct (does not share buffer references with the original)

Verified by: src/keychain/keychain.test.ts::"generateKeychain populates all eight slots with default algorithms" AND src/keychain/keychain.test.ts::"userId beyond the legacy 4-bit ceiling is supported" AND src/keychain/keychain.test.ts::"registering a custom purpose tag is non-breaking" AND src/keychain/keychain.test.ts::"parseKeychain rejects missing algorithm tag" AND src/keychain/keychain.test.ts::"parseKeychain rejects unknown algorithm tag" AND src/keychain/keychain.test.ts::"extractPublicKeys returns public-only keychain with no shared buffers" AND src/keychain/keychain.spec-conformance.test.ts::"slot map type is exact"


Requirement: DerivationLayout registry — pluggable u64 layout

The package 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), e.g. {tenantId: u24, userId: u24, scope: u16} for multi-tenant products.

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.

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 layout

  • WHEN defineDerivationLayout(1, customSchema) is called with a schema whose byteSize === 8 and generateKeychainSlot({ layoutId: 1, ... }) is then called
  • THEN the slot is generated; the resulting wire format records layoutId === 1; round-trip via parseKeychain recovers the layout-1 fields

Scenario: custom layout that exceeds 64 bits is rejected

  • WHEN defineDerivationLayout(2, oversizedSchema) is called with a schema whose byteSize > 8
  • THEN the call throws CryptoError with code KEYCHAIN.LAYOUT_TOO_WIDE

Scenario: parsing a payload with an unknown layout id fails distinctly

  • WHEN parseKeychain(bytes) is called on a payload whose layoutId is not registered
  • THEN the call throws CryptoError with code KEYCHAIN.UNKNOWN_LAYOUT, naming the unregistered id

Verified by: src/derivation/layouts.test.ts::"DERIVATION_LAYOUT_V1 fills exactly 8 bytes" AND src/derivation/layouts.test.ts::"custom 8-byte layout registers and round-trips" AND src/derivation/layouts.test.ts::"layout exceeding 8 bytes is rejected at registration" AND src/derivation/layouts.test.ts::"unknown layoutId at parse time throws KEYCHAIN.UNKNOWN_LAYOUT"


Requirement: Validity windows enforced at every verify call

The package 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; no production path calls Date.now() directly.

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.

Scenario: signing with an expired key fails

  • WHEN an Authority is asked to sign with a slot whose notAfter is before clock.now()
  • THEN the call throws CryptoError with code KEYCHAIN.EXPIRED, naming the slot and the expiry timestamp

Scenario: verifying a signature against a not-yet-valid key fails

  • WHEN verify(envelope) is called against a public keychain slot whose notBefore is after clock.now()
  • THEN the call throws CryptoError with code KEYCHAIN.NOT_YET_VALID

Scenario: relaxed mode allows reading an expired vault for inspection

  • WHEN inspectVault(bytes, { acceptExpired: true }) is called against a vault whose sign-slot expired
  • THEN the call succeeds and the returned keychain is annotated with expiredSlots: ['sign']

Verified by: src/keychain/keychain.test.ts::"signing with an expired slot throws KEYCHAIN.EXPIRED" AND src/keychain/keychain.test.ts::"verify with a not-yet-valid slot throws KEYCHAIN.NOT_YET_VALID" AND src/keychain/keychain.test.ts::"inspectVault with acceptExpired returns expiredSlots annotation" AND src/keychain/keychain.spec-conformance.test.ts::"no Date.now() call sites in src/"


Requirement: Domain-separation tag on every signed payload

The package 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 src/domain-tags/domain-tags.ts: DOMAIN_VAULT_V1, DOMAIN_PUBLIC_KEYCHAIN_V1, DOMAIN_PACKER_V1, DOMAIN_CHUNK_V1, DOMAIN_CRL_V1, DOMAIN_AUDIT_V1, DOMAIN_SLSA_BUILDER_V1.

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. This guards against cross-protocol signature replay.

Scenario: signing requires a tag

  • WHEN code attempts to call Authority.sign(payload, slotId) without supplying a domain tag
  • THEN the TypeScript compiler rejects the call (the domain parameter is non-optional and branded)

Scenario: cross-protocol replay fails

  • WHEN a payload signed under DOMAIN_VAULT_V1 is presented to a Packer unpack call (which expects DOMAIN_PACKER_V1)
  • THEN the verify step fails with CryptoError code SIGNATURE.DOMAIN_MISMATCH

Scenario: tag is part of the signed bytes, not metadata

  • WHEN an attacker swaps the tag prefix on a signed envelope without re-signing
  • THEN the signature verification fails (because the signed bytes included the original tag)

Verified by: src/authority/authority.test.ts::"sign without a domain tag fails to type-check" AND src/packer/packer.test.ts::"vault-tagged signature fails to verify under packer tag" AND src/packer/packer.test.ts::"swapping the tag on a signed envelope fails verification" AND src/packer/packer.spec-conformance.test.ts::"every domain tag is a branded string constant"


Requirement: Vault — three-regime serialisation with BIP39 restore and arbitrary application data

The package SHALL provide a Vault class that serialises a Keychain plus arbitrary application data using three distinct encryption regimes, mirroring the original KeyStore design (nx/libs/keys/keys-common.ts and the prior generateKeyStore implementation):

  1. Public bundle (plaintext) — the PUBLIC_BUNDLE_SCHEMA projection of the keychain, parent-signed, distributable. Carries no secrets.
  2. Private-key blob (password-encrypted, rotatable) — every slot's secretKey packed and AEAD-sealed under a password-derived key (Argon2id → XChaCha20-Poly1305). Re-encryptable on password change without re-deriving any keys or re-signing the public bundle.
  3. Vault payload (auth-box-sealed under the keypair) — the VAULT_PAYLOAD_SCHEMA carrying the master key, seedCount, the full keychain, and arbitrary data: Uint8Array (an opaque application-defined blob — the consumer supplies and validates its Zod schema). Sealed via crypto_box between the keychain's kx slot and itself, then signed by the parent under DOMAIN_VAULT_V1. Bound to the keypair, not the password.

The default AEAD for regimes 2 and 3 is XChaCha20-Poly1305 (192-bit nonce eliminates the nonce-reuse class of bugs in long-lived vault scenarios); AES-256-GCM is available as an explicit alternative. Argon2id parameters have a documented v1 floor (timeCost >= 3, memoryCost >= 65536, parallelism >= 4) enforced at vault creation; lower parameters are rejected.

The three-regime split delivers four properties the original single-regime sketch lacked:

  • changePassword(oldPwd, newPwd) re-encrypts the private-key blob in place; the public bundle and vault payload are untouched, preserving signatures and not requiring a re-derivation
  • rotateApplicationData(newData) re-seals the vault payload without touching keys
  • The public bundle is independently distributable and verifiable without ever opening the private regime
  • Application data inherits authenticated encryption bound to the keypair, so a stolen password alone cannot expose it

Vault exposes unlock(password) returning a Keychain, lock() zeroising every secret-key buffer, an unlockedKeychain() accessor that throws if lock() has been called, changePassword(oldPwd, newPwd), and rotateApplicationData(newData).

Scenario: pack and unpack round-trip

  • WHEN vault.pack(keychain, password) is called and the result is passed to Vault.unlock(packed, password)
  • THEN the returned keychain is structurally equal to the original

Scenario: tampered ciphertext fails to verify

  • WHEN any byte of the packed vault is mutated and Vault.unlock(packed, password) is called
  • THEN the call throws CryptoError with code VAULT.TAMPERED

Scenario: wrong password fails distinctly from tampering

  • WHEN Vault.unlock(packed, "wrong-password") is called
  • THEN the call throws CryptoError with code VAULT.BAD_PASSWORD (distinct from VAULT.TAMPERED, used by the CLI for distinct exit codes)

Scenario: BIP39 restore re-derives identical keychain

  • WHEN a vault is created with BIP39 mnemonic M and version V, and a fresh vault is constructed via restoreFromMnemonic(M, V)
  • THEN the restored keychain is byte-equal to the original

Scenario: lock zeroises secrets

  • WHEN vault.lock() is called after unlock
  • THEN every previously-held secret-key buffer is filled with zeros, asserted by post-lock buffer inspection

Scenario: Argon2id parameter floor

  • WHEN vault.pack(keychain, password, { argon2: { timeCost: 1 } }) is called (below floor)
  • THEN the call throws CryptoError with code VAULT.ARGON2_PARAMS_TOO_LOW

Scenario: changePassword re-encrypts the private-key blob without re-deriving

  • WHEN vault.changePassword(oldPwd, newPwd) is called on an unlocked vault
  • THEN the public bundle bytes and vault-payload bytes are unchanged (asserted by byte-equality); only the private-key blob differs; subsequent Vault.unlock(packed, newPwd) succeeds and Vault.unlock(packed, oldPwd) fails with VAULT.BAD_PASSWORD

Scenario: arbitrary application data round-trips through vault payload

  • WHEN vault.pack(keychain, password, { data: new TextEncoder().encode('{"appState":42}') }) is called and the result is unlocked
  • THEN the unlocked vault's data field byte-matches the input

Scenario: rotateApplicationData re-seals payload without touching keys

  • WHEN vault.rotateApplicationData(newData) is called on an unlocked vault
  • THEN the private-key blob is unchanged (byte-equality); the vault payload's signature still verifies under the parent's sign key; the new payload's data field matches newData

Verified by: src/vault/vault.test.ts::"pack and unlock round-trip" AND src/vault/vault.test.ts::"tampered ciphertext throws VAULT.TAMPERED" AND src/vault/vault.test.ts::"wrong password throws VAULT.BAD_PASSWORD" AND src/vault/vault.test.ts::"BIP39 restore yields byte-equal keychain" AND src/vault/vault.test.ts::"lock zeroises secret buffers" AND src/vault/vault.test.ts::"argon2 params below floor throw VAULT.ARGON2_PARAMS_TOO_LOW" AND src/vault/vault.test.ts::"changePassword re-encrypts private-key blob only" AND src/vault/vault.test.ts::"application data round-trips" AND src/vault/vault.test.ts::"rotateApplicationData preserves keys and parent signature" AND src/vault/vault.spec-conformance.test.ts::"vault wire format matches committed KAT"


Requirement: Public bundles are parent-signed and carry an issuer reference

The package 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 so callers can walk a chain. A parentSignPublicKey of null selects self-verify mode, in which the signature is verified against the bundle's own embedded sign slot — only valid for root identities, where issuerRef and the bundle's subject identity coincide.

The wire-format PUBLIC_BUNDLE_SCHEMA is:

Field Type Width Notes
magic bytes 4 ASCII "SVPB" (Suaveplan Public Bundle)
formatVersion uint 8 currently 1
issuerPurpose bytes 4 always "sign"
issuerApplication bytes 4 parent's application tag
issuerLayoutId uint 8 layoutId for the parent's coordinate fields
issuerMasterVersion + issuerUserId + issuerDepth + issuerSlotVersion uints 64 (per layout) parent's signing-slot derivation u64
issuedAt uint 53 ms since Unix epoch (replay protection)
nonce bytes 16 random per bundle (replay-cache key)
subjectUserId uint 32 who this bundle describes
subjectDepth uint 8 distance from root
slotCount uint 8 number of slot records that follow (default 8)
slots[slotCount] composite per KEY_SLOT_PUBLIC_SCHEMA each slot's coordinate + algorithm + validity + publicKey
parentSignature bytes 64 ed25519 over DOMAIN_PUBLIC_KEYCHAIN_V1 ‖ all preceding 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). Verifiers MUST accept self-signed bundles only when matched byte-for-byte against a pinned trust anchor (per the Trust-anchor bootstrap requirement); a self-signed bundle that does not match any pinned anchor fails with TRUST_ANCHOR.MISMATCH.

Scenario: parent-signed server bundle verifies against parent's sign slot

  • WHEN packPublicBundle(serverKeychain, rootAuthority) is called and the result is passed to unpackPublicBundle(bytes, root.sign.publicKey)
  • THEN the call succeeds; the returned issuerRef matches the root's signing-slot coordinate

Scenario: self-signed root bundle accepted only against pinned anchor

  • WHEN packPublicBundle(rootKeychain, rootAuthority) is called and the result is passed to unpackPublicBundle(bytes, null) (self-verify mode) with a matching pinned trust anchor in the registry
  • THEN the call succeeds

Scenario: self-signed bundle without a pinned anchor fails

  • WHEN the same self-signed root bundle is passed to unpackPublicBundle(bytes, null) with no matching pinned anchor
  • THEN the call throws CryptoError code TRUST_ANCHOR.MISMATCH

Scenario: tampered bundle fails verification

  • WHEN any byte of the packed bundle is mutated (including the issuerRef fields, the slot data, or the nonce)
  • THEN unpackPublicBundle(bytes, parentPublicKey) throws CryptoError code PUBLIC_KEYCHAIN.TAMPERED

Scenario: swapping issuerRef without re-signing fails

  • WHEN an attacker rewrites the issuerApplication field of a valid bundle and presents the result for verification
  • THEN the verification fails with PUBLIC_KEYCHAIN.TAMPERED — because the original issuerRef was part of the signed bytes

Verified by: src/keychain/public-bundle.test.ts::"server bundle signed by root verifies against root sign slot" AND src/keychain/public-bundle.test.ts::"self-signed root accepted against pinned anchor" AND src/keychain/public-bundle.test.ts::"self-signed root without pinned anchor throws TRUST_ANCHOR.MISMATCH" AND src/keychain/public-bundle.test.ts::"tampered bundle throws PUBLIC_KEYCHAIN.TAMPERED" AND src/keychain/public-bundle.test.ts::"swapping issuerRef without re-signing fails verification" AND src/keychain/public-bundle.spec-conformance.test.ts::"PUBLIC_BUNDLE_SCHEMA matches committed KAT"


The package SHALL provide a ChainBundle wire format and verifyChain(bundle, trustAnchor, clock, crl) walker. A ChainBundle is a header followed by a concatenation of PublicBundle records ordered leaf-first (index 0 is the entity being authenticated; the final index is the bundle whose issuerRef must match the supplied trust anchor).

The wire-format CHAIN_BUNDLE_SCHEMA header is:

Field Type Width Notes
magic bytes 4 ASCII "SVCB" (Suaveplan Chain Bundle)
formatVersion uint 8 currently 1
linkCount uint 8 1 ≤ linkCount ≤ 16

followed by linkCount × PUBLIC_BUNDLE records back-to-back. There is no chain-level signature — each link carries its own parent signature; chain integrity is the conjunction of every link's signature plus the final-link-against-trust-anchor check.

The verifyChain algorithm:

  1. For each link i from 0 to linkCount - 1:
    1. Locate the link's sign slot. Reject with KEYCHAIN.NOT_YET_VALID / KEYCHAIN.EXPIRED if clock.now() is outside the slot's [notBefore, notAfter].
    2. Reject with AUTHORITY.REVOKED if the link's issuerRef matches a CRL entry.
    3. Identify the parent: link i + 1 if it exists, otherwise trustAnchor.
    4. Reject with CHAIN.ISSUER_MISMATCH if the parent's signing-slot coordinate does not equal the link's issuerRef.
    5. Verify the parent's signature over the link's bytes; reject with SIGNATURE.INVALID on failure.
  2. Reject with TRUST_ANCHOR.MISMATCH if the final link's parent (the trust anchor) does not byte-match the supplied anchor.
  3. On success, return the leaf's parsed keychain plus the full verified chain (so callers can audit "who issued whom").

linkCount is bounded at 16 (matching depth's 8-bit ceiling at the default layout's u8 — practically deeper chains have no use case and an unbounded chain is a denial-of-service vector). Chains exceeding the bound fail at parse time with CHAIN.TOO_DEEP.

  • WHEN a chain bundle is built as [subService, server, root] (each parent-signed by the next), and verifyChain(chain, rootAnchor, clock, emptyCrl) is called
  • THEN the call succeeds; the returned chain has length 3; each link's issuerRef matches the next link's signing-slot coordinate
  • WHEN the server link in the chain above has any byte mutated
  • THEN verifyChain throws SIGNATURE.INVALID with the link index (1) attached to the error context
  • WHEN the CRL contains an entry for the server link's issuerRef and verifyChain is called
  • THEN the call throws AUTHORITY.REVOKED with link index 1

Scenario: chain whose root does not match the trust anchor fails

  • WHEN the chain's final link is signed by a different root than the supplied trust anchor
  • THEN verifyChain throws TRUST_ANCHOR.MISMATCH
  • WHEN a malformed payload claims linkCount = 17 and parseChainBundle(bytes) is called
  • THEN the call throws CryptoError code CHAIN.TOO_DEEP
  • WHEN a chain with linkCount === 1 is verified and the single link is self-signed
  • THEN the call succeeds iff the single link byte-matches the supplied trust anchor; otherwise fails with TRUST_ANCHOR.MISMATCH

Verified by: src/keychain/chain-bundle.test.ts::"three-link chain verifies end-to-end" AND src/keychain/chain-bundle.test.ts::"tampering with a middle link fails at that link" AND src/keychain/chain-bundle.test.ts::"revoked intermediate link fails verification" AND src/keychain/chain-bundle.test.ts::"chain whose root mismatches anchor throws TRUST_ANCHOR.MISMATCH" AND src/keychain/chain-bundle.test.ts::"chain longer than 16 links throws CHAIN.TOO_DEEP" AND src/keychain/chain-bundle.test.ts::"single-link chain matching anchor succeeds" AND src/keychain/chain-bundle.spec-conformance.test.ts::"CHAIN_BUNDLE_SCHEMA matches committed KAT"


Requirement: Wire-format schemas exposed and committed

The package SHALL export the following bit-pack schemas as named, committed module exports — they are the canonical wire format and any change requires a formatVersion bump and a new committed KAT fixture:

  • DERIVATION_LAYOUT_V1 — 8-byte subkey_id layout
  • DERIVATION_COORDINATE_SCHEMA — purpose ‖ application ‖ layoutId ‖ <layout fields> (≈25 bytes for v1)
  • KEY_SLOT_PRIVATE_SCHEMA — coordinate + algorithm + validity + secretKey + publicKey + hasPublic
  • KEY_SLOT_PUBLIC_SCHEMA — same shape, secretKey omitted
  • VAULT_PAYLOAD_SCHEMA — magic("SVVP") ‖ formatVersion ‖ seedCount ‖ slotCount ‖ slots[] ‖ dataLength ‖ dataBytes; trailing parentSignature: 64 bytes; whole payload password-AEAD-encrypted
  • PUBLIC_BUNDLE_SCHEMA — header above + slots + trailing parentSignature: 64 bytes
  • CHAIN_BUNDLE_SCHEMA — header + linkCount × PUBLIC_BUNDLE
  • CRL_ENTRY_SCHEMA — issuer ref, revoked coordinate, reason code, revokedAt
  • AUDIT_ENTRY_SCHEMA — event type (issue / roll / revoke), subject coordinate, prior-entry hash, issuedAt

Each schema is a BitSchema<T> from @suaveplan/bitpacker; the corresponding type T is exported alongside. A test asserts schema.byteSize (or computed size for variable schemas) against a committed constant in src/wire-format/sizes.ts, so any field-width change is loud.

Scenario: every wire-format schema is module-exported

  • WHEN the package's src/index.ts is parsed
  • THEN each named schema above is exported with the documented type alias

Scenario: schema byte-sizes match committed constants

  • WHEN the schema-size test runs
  • THEN KEY_SLOT_PUBLIC_SCHEMA.byteSize === EXPECTED_KEY_SLOT_PUBLIC_BYTES (and equivalent for every fixed-size schema)

Verified by: src/wire-format/exports.test.ts::"every wire-format schema is exported" AND src/wire-format/sizes.test.ts::"schema byte-sizes match committed constants"


Requirement: Authority — signing abstraction over hierarchical identity

The package SHALL define an Authority interface with method sign(domain: DomainTag, slotId: SlotId, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> and dispose(): void. The interface is the only signing surface exposed to callers; raw secretKey: Uint8Array references are never 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 dropped in without changes to callers.

dispose() MUST zeroise any held secret material; calling sign() after dispose() MUST throw CryptoError code AUTHORITY.DISPOSED.

Scenario: sign through Authority interface

  • WHEN authority.sign(DOMAIN_PACKER_V1, "sign", payload) is called against an InMemoryAuthority holding a valid keychain
  • THEN the returned signature verifies against the corresponding public sign slot

Scenario: sign with revoked slot fails

  • WHEN the slot has been revoked via the active RevocationList and authority.sign(..., signal) is called
  • THEN the call throws CryptoError code AUTHORITY.REVOKED before any signing primitive is invoked

Scenario: dispose zeroises and locks the authority

  • WHEN authority.dispose() is called and then authority.sign(...) is invoked
  • THEN the call throws CryptoError code AUTHORITY.DISPOSED; pre-zeroise inspection shows secret bytes, post-zeroise inspection shows zero buffers

Scenario: AbortSignal cancellation

  • WHEN authority.sign(..., signal) is called with an already-aborted signal
  • THEN the call rejects with CryptoError code AUTHORITY.ABORTED

Verified by: src/authority/authority.test.ts::"sign through Authority verifies against public slot" AND src/authority/authority.test.ts::"sign with revoked slot throws AUTHORITY.REVOKED" AND src/authority/authority.test.ts::"dispose zeroises secrets and locks the authority" AND src/authority/authority.test.ts::"sign with aborted signal throws AUTHORITY.ABORTED" AND src/authority/authority.spec-conformance.test.ts::"Authority conformance harness for future backends"


Requirement: Hierarchical identity — Root and Server

The package SHALL provide createRoot(options): Promise<RootIdentity>, createServer(root: Authority, options): Promise<ServerIdentity>, rollServer(root: Authority, server: ServerIdentity): Promise<ServerIdentity>, and revokeServer(root: Authority, server: ServerIdentity, reason): Promise<RevocationEntry>.

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 produces a new ServerIdentity with version + 1 and emits a RevocationEntry for the prior version into the active RevocationList. revokeServer emits a RevocationEntry without re-issuing.

Every issuance, roll, and revoke MUST append a corresponding entry to the active AuditLog.

Scenario: createRoot produces a self-verifying root identity

  • WHEN createRoot({ userId: 0 }) is called
  • THEN the result's public bundle verifies against its own sign slot (self-verifying mode)

Scenario: createServer is signed by root

  • WHEN createServer(rootAuthority, { userId: 1 }) is called
  • THEN the server's public bundle verifies against the root's public sign key (not the server's own)

Scenario: rollServer revokes prior version

  • WHEN rollServer(rootAuthority, server@v1) is called
  • THEN the result is server@v2, the active RevocationList contains a signed entry for server@v1, and the AuditLog contains both a roll entry and a revoke entry

Scenario: revokeServer does not re-issue

  • WHEN revokeServer(rootAuthority, server@v1, "compromised") is called
  • THEN the active RevocationList contains a signed entry for server@v1, the AuditLog contains a revoke entry, and no new server identity is produced

Verified by: src/identity/root-identity.test.ts::"createRoot produces self-verifying identity" AND src/identity/server-identity.test.ts::"createServer is signed by root sign slot" AND src/identity/server-identity.test.ts::"rollServer increments version and revokes prior" AND src/identity/server-identity.test.ts::"revokeServer adds entry without re-issuing" AND src/identity/server-identity.spec-conformance.test.ts::"every issue/roll/revoke appends to AuditLog"


Requirement: RevocationList — root-signed CRL

The package 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(trustAnchor), and isRevoked(serverIdentityRef): boolean.

Scenario: append produces a verifiable list

  • WHEN crl.append({serverId, version, reason, revokedAt}, rootAuthority) is called
  • THEN crl.verify(rootPublicKey) returns true

Scenario: mutating a past entry invalidates the list

  • WHEN any byte of any past entry is mutated
  • THEN crl.verify(rootPublicKey) returns false

Scenario: isRevoked reports correctly

  • WHEN a server identity at version 1 is appended to the CRL, and crl.isRevoked({serverId, version: 1}) is called
  • THEN the call returns true; calling crl.isRevoked({serverId, version: 2}) returns false

Verified by: src/revocation/revocation.test.ts::"appended CRL verifies against root anchor" AND src/revocation/revocation.test.ts::"mutated past entry invalidates CRL signature" AND src/revocation/revocation.test.ts::"isRevoked reports per version correctly"


Requirement: AuditLog — append-only Merkle log

The package 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), verify(trustAnchor), proveInclusion(eventId): MerkleProof, and verifyInclusion(eventId, proof, root): boolean.

Scenario: append produces a verifiable log

  • WHEN an issue event is appended via log.append(event, rootAuthority)
  • THEN log.verify(rootPublicKey) returns true and log.proveInclusion(event.id) returns a proof that verifies against the root

Scenario: tampering with a past entry breaks the Merkle root

  • WHEN any byte of any past event is mutated
  • THEN log.verify(rootPublicKey) returns false

Verified by: src/audit-log/audit-log.test.ts::"appended log verifies against root anchor" AND src/audit-log/audit-log.test.ts::"inclusion proof verifies against signed root" AND src/audit-log/audit-log.test.ts::"mutating a past entry invalidates the Merkle root"


Requirement: Packer — encrypt-and-sign envelope

The package SHALL provide Packer and Unpacker classes implementing a schema-driven encrypt-and-sign envelope over bitpacker. The envelope is structured [domain-tag][nonce][issuedAt][nonceBytes][packed-bytes][signature]; the signature covers everything except itself; the packed-bytes section is AEAD-encrypted with the recipient's encrypt slot.

Packer.pack(payload, recipientPublicKey, senderAuthority) returns the envelope bytes; Unpacker.unpack(envelope, recipientAuthority, senderPublicKey) returns the original payload after verifying the signature against senderPublicKey and decrypting under recipientAuthority's encrypt slot.

Verification MUST reject envelopes whose issuedAt is older than a configurable replay window (default 24 hours) or whose nonceBytes value has been seen before in a configurable replay-cache window.

Scenario: round-trip encrypt-and-sign

  • WHEN packer.pack(payload, recipientPublic, senderAuth) is called and the result is passed to unpacker.unpack(envelope, recipientAuth, senderPublic)
  • THEN the returned payload is byte-equal to the original

Scenario: tampered ciphertext fails

  • WHEN any byte of the encrypted payload section is mutated
  • THEN unpacker.unpack(...) throws CryptoError code PACKER.SIGNATURE_INVALID or PACKER.AEAD_TAG_INVALID (whichever fails first)

Scenario: replay rejected by issuedAt

  • WHEN an envelope with issuedAt = clock.now() - 25h is presented (default 24h window)
  • THEN the call throws CryptoError code PACKER.REPLAY_TOO_OLD

Scenario: replay rejected by nonce cache

  • WHEN the same envelope is unpacked twice in succession against the same Unpacker
  • THEN the first call succeeds; the second throws CryptoError code PACKER.REPLAY_NONCE_SEEN

Scenario: domain mismatch rejected

  • WHEN an envelope packed under DOMAIN_VAULT_V1 is presented to a Unpacker configured for DOMAIN_PACKER_V1
  • THEN the call throws CryptoError code SIGNATURE.DOMAIN_MISMATCH

Verified by: src/packer/packer.test.ts::"round-trip encrypt-and-sign" AND src/packer/packer.test.ts::"tampered ciphertext fails verification" AND src/packer/packer.test.ts::"old envelope rejected by replay window" AND src/packer/packer.test.ts::"repeated envelope rejected by nonce cache" AND src/packer/packer.test.ts::"cross-domain envelope rejected" AND src/packer/packer.spec-conformance.test.ts::"packer envelope wire format matches committed KAT" AND src/packer/packer.test.ts::"seals to recipient and verifies sender"


Requirement: ChunkedPacker — per-chunk-signed P2P fragmentation

The package SHALL provide ChunkedPacker.packFileToChunks(fileId, payload, senderAuthority, recipientPublic?, options) returning an array of independently-signed chunks, each chunk an envelope in the same form as Packer but additionally tagged with {fileId, chunkId, totalChunks, checksumOfWhole} and signed under DOMAIN_CHUNK_V1.

ChunkedUnpacker.reassembleFile(chunks, recipientAuthority?, senderPublic) verifies every chunk independently; any tampered, missing, or out-of-order chunk causes reassembly to fail with a structured error naming the offending chunk. Successful reassembly verifies the whole-file checksum embedded in each chunk's metadata.

Reassembly emits chunk:verified, reassemble:progress, and reassemble:complete events via @suaveplan/events.

Scenario: round-trip over many chunks

  • WHEN a 10MB payload is fragmented into 1MB chunks and then reassembled
  • THEN the reassembled bytes are byte-equal to the original payload

Scenario: tampered chunk fails reassembly

  • WHEN one chunk is mutated and reassembleFile(chunks, ...) is called
  • THEN the call throws CryptoError code CHUNK.SIGNATURE_INVALID, naming the chunkId

Scenario: missing chunk fails reassembly

  • WHEN one chunk is omitted from the chunk array
  • THEN the call throws CryptoError code CHUNK.MISSING, naming the missing chunkId

Scenario: chunk from wrong file fails

  • WHEN a chunk with fileId = X is included alongside chunks with fileId = Y
  • THEN the call throws CryptoError code CHUNK.WRONG_FILE

Scenario: streaming reassembly via Web Streams

  • WHEN reassembleStream(chunkIterable, ...) is called with an async iterable of chunks
  • THEN the result is a ReadableStream<Uint8Array> that yields the reassembled payload progressively, with reassemble:progress events for each verified chunk

Verified by: src/chunked-packer/chunked-packer.test.ts::"round-trip over many chunks" AND src/chunked-packer/chunked-packer.test.ts::"tampered chunk fails reassembly with chunk id" AND src/chunked-packer/chunked-packer.test.ts::"missing chunk fails reassembly with chunk id" AND src/chunked-packer/chunked-packer.test.ts::"chunk from wrong file rejected" AND src/chunked-packer/chunked-packer.test.ts::"streaming reassembly emits progress events" AND src/chunked-packer/chunked-packer.spec-conformance.test.ts::"chunk wire format matches committed KAT"


Requirement: Replay protection via issuedAt + nonce

The package SHALL include issuedAt (milliseconds since Unix epoch, read from injectable Clock) and nonce (16 random bytes from injectable Rng) in every signed envelope. Verifiers MUST reject envelopes whose issuedAt is outside a configurable window relative to the verifier's clock and whose nonce has already been seen within a configurable nonce-cache window.

The replay window default is 24 hours; the nonce-cache size default is 10000 entries with LRU eviction. Both are constructor options on Packer / ChunkedPacker / Unpacker / ChunkedUnpacker.

Scenario: clock skew within window is accepted

  • WHEN an envelope with issuedAt = verifier.clock.now() - 23h is presented (within 24h window)
  • THEN the call succeeds

Scenario: nonce cache is bounded

  • WHEN more nonces than the cache size are presented in succession (with monotonically increasing issuedAt)
  • THEN the cache evicts the oldest; the LRU eviction policy is asserted by introspecting cache state in the test

Verified by: src/packer/replay-protection.test.ts::"envelope within replay window accepted" AND src/packer/replay-protection.test.ts::"nonce cache evicts in LRU order"


Requirement: Wire-format KAT vectors

The package SHALL ship golden-bytes test fixtures at test-fixtures/wire-format/ for every wire format: vault-v1.bin, public-keychain-v1.bin, packer-envelope-v1.bin, chunk-v1.bin, crl-v1.bin, audit-entry-v1.bin. Each fixture is generated from a documented set of inputs (deterministic via fixed Clock and seeded Rng); a test asserts byte-for-byte equality between the live encoder output and the committed fixture.

This requirement guards against silent wire-format regression in any future refactor.

Scenario: every wire format has a committed fixture

  • WHEN the wire-format KAT test runs
  • THEN every encoder named in this requirement is exercised against its committed fixture and produces byte-equal output

Scenario: a regenerator script exists and is documented

  • WHEN bun scripts/regenerate-wire-format-kats.ts is run
  • THEN the fixtures are regenerated from documented inputs; the script's output diff is reviewed in PR (regeneration without review is a deliberate, human-gated action)

Verified by: tests/wire-format-kat.test.ts::"vault-v1 fixture matches encoder output" AND tests/wire-format-kat.test.ts::"public-keychain-v1 fixture matches encoder output" AND tests/wire-format-kat.test.ts::"packer-envelope-v1 fixture matches encoder output" AND tests/wire-format-kat.test.ts::"chunk-v1 fixture matches encoder output" AND tests/wire-format-kat.test.ts::"crl-v1 fixture matches encoder output" AND tests/wire-format-kat.test.ts::"audit-entry-v1 fixture matches encoder output"


Requirement: Property-based round-trip coverage

The package SHALL include fast-check property tests asserting round-trip equality for every pack/unpack pair: packKeychain/parseKeychain, Vault.pack/Vault.unlock, Packer.pack/Unpacker.unpack, ChunkedPacker.packFileToChunks/ChunkedUnpacker.reassembleFile. Each property runs at least 100 cases per CI invocation.

Scenario: keychain round-trip property

  • WHEN fast-check generates an arbitrary valid Keychain and the test runs parseKeychain(packKeychain(kc))
  • THEN the result is structurally equal to the input

Verified by: tests/property-roundtrips.test.ts::"keychain pack/parse round-trip" AND tests/property-roundtrips.test.ts::"vault pack/unlock round-trip" AND tests/property-roundtrips.test.ts::"packer pack/unpack round-trip" AND tests/property-roundtrips.test.ts::"chunked-packer fragment/reassemble round-trip"


Requirement: Trust-anchor bootstrap

The package SHALL document and support two trust-anchor distribution patterns and reject all other paths in v1: (a) compiled-in pinned constant — the trust anchor's public sign key is exported from src/trust-anchors/<anchor-name>.ts as a named Uint8Array constant, ideally derived from a committed .pub fixture; (b) environment-variable path — SUAVEPLAN_TRUST_ANCHOR_PATH points at a file containing the public bundle, loaded once at startup.

Network fetching, TUF-style updaters, and Sigstore-Fulcio integration are explicitly out of scope for v1.

Scenario: compiled-in trust anchor is loadable

  • WHEN loadTrustAnchor("suaveplan-builder") is called and src/trust-anchors/suaveplan-builder.ts exports a valid public bundle
  • THEN the call returns a verified Keychain (public-only)

Scenario: env-var path is loadable

  • WHEN SUAVEPLAN_TRUST_ANCHOR_PATH=/path/to/anchor.pub is set and loadTrustAnchorFromEnv() is called
  • THEN the call returns the loaded bundle, verifying it as a self-verifying public bundle before returning

Scenario: missing trust anchor fails distinctly

  • WHEN neither pattern resolves to a bundle
  • THEN the call throws CryptoError code TRUST_ANCHOR.MISSING (used by the CLI for distinct exit code)

Verified by: src/trust-anchors/trust-anchors.test.ts::"compiled-in anchor loads and verifies" AND src/trust-anchors/trust-anchors.test.ts::"env-var path loads and verifies" AND src/trust-anchors/trust-anchors.test.ts::"missing trust anchor throws TRUST_ANCHOR.MISSING"


Requirement: OpenTelemetry instrumentation with key-byte redaction

The package SHALL emit OTel spans, counters, and histograms for every public operation (sign, verify, pack, unpack, fragment, reassemble, issue, roll, revoke, append-CRL, append-audit). Span attributes MUST NOT contain any cryptographic material — no secret-key bytes, no public-key bytes longer than a documented threshold (16 bytes max), no plaintext payloads, no nonces. A redaction test asserts this by stress-testing every public path against a real in-memory MeterProvider and SpanProcessor.

Scenario: every public operation emits a span

  • WHEN any public method is called
  • THEN at least one span is emitted with the operation name as the span name

Scenario: no key bytes appear in span attributes

  • WHEN the redaction test runs every public path
  • THEN no span attribute on any span exceeds the documented byte-size threshold for keying material

Verified by: src/observability/instrumentation.test.ts::"every public operation emits a span" AND src/observability/instrumentation.test.ts::"span attributes redact keying material"


Requirement: Structured error codes with stable identifiers

The package SHALL define stable error codes via defineCodes in src/errors/codes.ts, organised by domain: KEYCHAIN.*, VAULT.*, AUTHORITY.*, PACKER.*, CHUNK.*, CRL.*, AUDIT.*, SIGNATURE.*, TRUST_ANCHOR.*, PUBLIC_KEYCHAIN.*. Every code MUST be thrown by at least one production path; an unused-code test SHALL assert this. Error messages MUST be i18n-keyed via @suaveplan/i18n; no hard-coded user-visible strings.

Scenario: every error code is thrown by a production code path

  • WHEN the unused-code test enumerates every code defined in src/errors/codes.ts and greps src/ for a throw new CryptoError(<code>, ...) site
  • THEN every code has at least one matching site

Scenario: every error message is resolved from an i18n key

  • WHEN an error is thrown with a message
  • THEN the message is the result of i18n.resolve(<key>) rather than a string literal

Scenario: failure-class codes route to the documented domain

  • WHEN a vault-tampering scenario triggers an error
  • THEN the error's code is in the VAULT.* namespace (not KEYCHAIN.* / AUTHORITY.* / etc.)

Verified by: src/errors/codes.test.ts::"every code is thrown by a production path" AND src/errors/codes.test.ts::"every error message is an i18n key" AND src/errors/codes.test.ts::"failure-class codes route to the documented domain"