47 KiB
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. (Seeopenspec/AGENTS.mdSection 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—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 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
DerivationCoordinatecarriesapplication === "chat",userId === 123_456,masterVersion === 1,depth === 1,slotVersion === 1, andnotBefore <= 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_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: registering a custom purpose tag is non-breaking
- WHEN a caller invokes
registerPurpose("audi")and thengenerateKeychainSlot({ purpose: "audi", ... }) - THEN the slot is generated normally; existing pre-registration keychains remain parseable; the
purposebyte 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
CryptoErrorwith codeKEYCHAIN.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
CryptoErrorwith codeKEYCHAIN.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
publicKeyper slot; everysecretKeyfield isundefinedand 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_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 layout
- WHEN
defineDerivationLayout(1, customSchema)is called with a schema whosebyteSize === 8andgenerateKeychainSlot({ layoutId: 1, ... })is then called - THEN the slot is generated; the resulting wire format records
layoutId === 1; round-trip viaparseKeychainrecovers the layout-1 fields
Scenario: custom layout that exceeds 64 bits is rejected
- WHEN
defineDerivationLayout(2, oversizedSchema)is called with a schema whosebyteSize > 8 - THEN the call throws
CryptoErrorwith codeKEYCHAIN.LAYOUT_TOO_WIDE
Scenario: parsing a payload with an unknown layout id fails distinctly
- WHEN
parseKeychain(bytes)is called on a payload whoselayoutIdis not registered - THEN the call throws
CryptoErrorwith codeKEYCHAIN.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
Authorityis asked to sign with a slot whosenotAfteris beforeclock.now() - THEN the call throws
CryptoErrorwith codeKEYCHAIN.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 whosenotBeforeis afterclock.now() - THEN the call throws
CryptoErrorwith codeKEYCHAIN.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
domainparameter is non-optional and branded)
Scenario: cross-protocol replay fails
- WHEN a payload signed under
DOMAIN_VAULT_V1is presented to aPackerunpack call (which expectsDOMAIN_PACKER_V1) - THEN the verify step fails with
CryptoErrorcodeSIGNATURE.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):
- Public bundle (plaintext) — the
PUBLIC_BUNDLE_SCHEMAprojection of the keychain, parent-signed, distributable. Carries no secrets. - Private-key blob (password-encrypted, rotatable) — every slot's
secretKeypacked 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. - Vault payload (auth-box-sealed under the keypair) — the
VAULT_PAYLOAD_SCHEMAcarrying the master key,seedCount, the full keychain, and arbitrarydata: Uint8Array(an opaque application-defined blob — the consumer supplies and validates its Zod schema). Sealed viacrypto_boxbetween the keychain'skxslot and itself, then signed by the parent underDOMAIN_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-derivationrotateApplicationData(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 toVault.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
CryptoErrorwith codeVAULT.TAMPERED
Scenario: wrong password fails distinctly from tampering
- WHEN
Vault.unlock(packed, "wrong-password")is called - THEN the call throws
CryptoErrorwith codeVAULT.BAD_PASSWORD(distinct fromVAULT.TAMPERED, used by the CLI for distinct exit codes)
Scenario: BIP39 restore re-derives identical keychain
- WHEN a vault is created with BIP39 mnemonic
Mand versionV, and a fresh vault is constructed viarestoreFromMnemonic(M, V) - THEN the restored keychain is byte-equal to the original
Scenario: lock zeroises secrets
- WHEN
vault.lock()is called afterunlock - 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
CryptoErrorwith codeVAULT.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 andVault.unlock(packed, oldPwd)fails withVAULT.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
datafield 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
datafield matchesnewData
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 tounpackPublicBundle(bytes, root.sign.publicKey) - THEN the call succeeds; the returned
issuerRefmatches 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 tounpackPublicBundle(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
CryptoErrorcodeTRUST_ANCHOR.MISMATCH
Scenario: tampered bundle fails verification
- WHEN any byte of the packed bundle is mutated (including the
issuerReffields, the slot data, or the nonce) - THEN
unpackPublicBundle(bytes, parentPublicKey)throwsCryptoErrorcodePUBLIC_KEYCHAIN.TAMPERED
Scenario: swapping issuerRef without re-signing fails
- WHEN an attacker rewrites the
issuerApplicationfield of a valid bundle and presents the result for verification - THEN the verification fails with
PUBLIC_KEYCHAIN.TAMPERED— because the originalissuerRefwas 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"
Requirement: Chain bundle traversal — concatenated parent-signed links
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:
- For each link
ifrom 0 tolinkCount - 1:- Locate the link's
signslot. Reject withKEYCHAIN.NOT_YET_VALID/KEYCHAIN.EXPIREDifclock.now()is outside the slot's[notBefore, notAfter]. - Reject with
AUTHORITY.REVOKEDif the link'sissuerRefmatches a CRL entry. - Identify the parent: link
i + 1if it exists, otherwisetrustAnchor. - Reject with
CHAIN.ISSUER_MISMATCHif the parent's signing-slot coordinate does not equal the link'sissuerRef. - Verify the parent's signature over the link's bytes; reject with
SIGNATURE.INVALIDon failure.
- Locate the link's
- Reject with
TRUST_ANCHOR.MISMATCHif the final link's parent (the trust anchor) does not byte-match the supplied anchor. - 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.
Scenario: three-link chain (sub-service → server → root) verifies
- WHEN a chain bundle is built as
[subService, server, root](each parent-signed by the next), andverifyChain(chain, rootAnchor, clock, emptyCrl)is called - THEN the call succeeds; the returned chain has length 3; each link's
issuerRefmatches the next link's signing-slot coordinate
Scenario: tampering with a middle link fails at that link
- WHEN the
serverlink in the chain above has any byte mutated - THEN
verifyChainthrowsSIGNATURE.INVALIDwith the link index (1) attached to the error context
Scenario: revoked intermediate fails at that link
- WHEN the CRL contains an entry for the
serverlink'sissuerRefandverifyChainis called - THEN the call throws
AUTHORITY.REVOKEDwith 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
verifyChainthrowsTRUST_ANCHOR.MISMATCH
Scenario: chain longer than 16 links is rejected
- WHEN a malformed payload claims
linkCount = 17andparseChainBundle(bytes)is called - THEN the call throws
CryptoErrorcodeCHAIN.TOO_DEEP
Scenario: single-link chain is a self-signed root
- WHEN a chain with
linkCount === 1is 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-bytesubkey_idlayoutDERIVATION_COORDINATE_SCHEMA—purpose ‖ application ‖ layoutId ‖ <layout fields>(≈25 bytes for v1)KEY_SLOT_PRIVATE_SCHEMA— coordinate + algorithm + validity + secretKey + publicKey + hasPublicKEY_SLOT_PUBLIC_SCHEMA— same shape, secretKey omittedVAULT_PAYLOAD_SCHEMA—magic("SVVP") ‖ formatVersion ‖ seedCount ‖ slotCount ‖ slots[] ‖ dataLength ‖ dataBytes; trailingparentSignature: 64 bytes; whole payload password-AEAD-encryptedPUBLIC_BUNDLE_SCHEMA— header above + slots + trailingparentSignature: 64 bytesCHAIN_BUNDLE_SCHEMA— header +linkCount × PUBLIC_BUNDLECRL_ENTRY_SCHEMA— issuer ref, revoked coordinate, reason code, revokedAtAUDIT_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.tsis 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 anInMemoryAuthorityholding 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
RevocationListandauthority.sign(..., signal)is called - THEN the call throws
CryptoErrorcodeAUTHORITY.REVOKEDbefore any signing primitive is invoked
Scenario: dispose zeroises and locks the authority
- WHEN
authority.dispose()is called and thenauthority.sign(...)is invoked - THEN the call throws
CryptoErrorcodeAUTHORITY.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
CryptoErrorcodeAUTHORITY.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 activeRevocationListcontains a signed entry forserver@v1, and theAuditLogcontains both arollentry and arevokeentry
Scenario: revokeServer does not re-issue
- WHEN
revokeServer(rootAuthority, server@v1, "compromised")is called - THEN the active
RevocationListcontains a signed entry forserver@v1, theAuditLogcontains arevokeentry, 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
issueevent is appended vialog.append(event, rootAuthority) - THEN
log.verify(rootPublicKey)returns true andlog.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 tounpacker.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(...)throwsCryptoErrorcodePACKER.SIGNATURE_INVALIDorPACKER.AEAD_TAG_INVALID(whichever fails first)
Scenario: replay rejected by issuedAt
- WHEN an envelope with
issuedAt = clock.now() - 25his presented (default 24h window) - THEN the call throws
CryptoErrorcodePACKER.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
CryptoErrorcodePACKER.REPLAY_NONCE_SEEN
Scenario: domain mismatch rejected
- WHEN an envelope packed under
DOMAIN_VAULT_V1is presented to aUnpackerconfigured forDOMAIN_PACKER_V1 - THEN the call throws
CryptoErrorcodeSIGNATURE.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
CryptoErrorcodeCHUNK.SIGNATURE_INVALID, naming the chunkId
Scenario: missing chunk fails reassembly
- WHEN one chunk is omitted from the chunk array
- THEN the call throws
CryptoErrorcodeCHUNK.MISSING, naming the missing chunkId
Scenario: chunk from wrong file fails
- WHEN a chunk with
fileId = Xis included alongside chunks withfileId = Y - THEN the call throws
CryptoErrorcodeCHUNK.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, withreassemble:progressevents 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() - 23his 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.tsis 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
Keychainand the test runsparseKeychain(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 andsrc/trust-anchors/suaveplan-builder.tsexports 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.pubis set andloadTrustAnchorFromEnv()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
CryptoErrorcodeTRUST_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.tsand grepssrc/for athrow 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 (notKEYCHAIN.*/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"