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

656 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"`
---
### 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:
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`.
#### 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), 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
#### Scenario: tampering with a middle link fails at that link
- **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
#### Scenario: revoked intermediate fails at that link
- **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`
#### Scenario: chain longer than 16 links is rejected
- **WHEN** a malformed payload claims `linkCount = 17` and `parseChainBundle(bytes)` is called
- **THEN** the call throws `CryptoError` code `CHAIN.TOO_DEEP`
#### Scenario: single-link chain is a self-signed root
- **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"`