Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,655 @@
# 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"`