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,2 @@
schema: spec-driven
created: 2026-05-03

View File

@@ -0,0 +1,117 @@
## Context
The `restore-crypto-keychain-packer-umbrella` change was sliced into four sequential children on 2026-05-03 (`restore-crypto-cleanup-duplicates`, `restore-crypto-fold-keys-vault`, `restore-crypto-opinionated-layer`, `restore-crypto-cli-and-allowlist`). Each child shipped its source-code delta. The umbrella itself was archived to `openspec/archive/2026-05-03-restore-crypto-keychain-packer-umbrella/` with its 20-requirement spec under `specs/crypto/spec.md`. The slice plan implicitly assumed each child would carry its share of the umbrella's spec deltas into `openspec/specs/crypto/spec.md`. That did not happen. The current live spec at `openspec/specs/crypto/spec.md` carries primitive-substrate requirements, the `keychain` CLI surface, the streaming-AEAD façade, and password-blob helpers — but the central "every key carries `(masterVersion, slotVersion)`" invariant is absent at the requirement layer. The supporting source code shipped (verified by direct inspection of `packages/crypto/crypto/src/derivation/{coordinate,layouts}/{coordinate,layouts}.ts` and the test files alongside them).
In parallel, `openspec/specs/crypto-keys/spec.md` exists as a stub: `Purpose: TBD - created by archiving change crypto-completion`. That stub describes a `BaseKeySchema` device-key surface for a `@suaveplan/crypto-keys` package that no longer exists on genesis (`packages/crypto/crypto-keys/` is absent). Live `openspec/specs/crypto/spec.md:204-207` asserts the package must be deleted; the stub contradicts it.
This is a documentation-debt change. The code is correct and tested. The spec corpus is incomplete and self-contradictory.
## Goals / Non-Goals
**Goals:**
- Restore the versioned-key vocabulary (`DerivationCoordinate`, `DerivationLayout`, `masterVersion`, `slotVersion`, eight-slot `Keychain`, validity windows, parent-signed bundles, hierarchical identity, revocation, audit, packer, replay protection, trust-anchor) as named requirements in `openspec/specs/crypto/spec.md`.
- Every new requirement carries `Verified by:` pointers to existing test files at concrete `path::"test name"` granularity.
- Eliminate the contradiction between `openspec/specs/crypto/spec.md` (says crypto-keys is gone) and `openspec/specs/crypto-keys/spec.md` (still describes it) by removing the orphan stub.
- After archive, `openspec validate --strict` is green for the live `crypto` capability and `openspec/specs/crypto-keys/` no longer exists.
**Non-Goals:**
- No source-code changes. No new tests. No deleted tests. No moved files in `packages/`.
- No new requirements that the existing test suite does not already cover. If the umbrella had a requirement (e.g. a specific CRL fetch endpoint) that has no current test, it is **not** folded — it gets a follow-up implementation change.
- No reopening of any of the four archived child changes (`restore-crypto-cleanup-duplicates`, `restore-crypto-fold-keys-vault`, `restore-crypto-opinionated-layer`, `restore-crypto-cli-and-allowlist`). Their archives remain authoritative for their respective slices.
- No reorganisation of unrelated requirements (primitive substrate, AEAD façade, password blob, allowlist gate). Their text is unchanged.
- No spec changes for `bitpacker-crypto`, `crypto-cli`, `crypto-core`, `slsa`. The umbrella touched their specs too; those folds are out of scope here and tracked as separate follow-ups if found necessary.
## Decisions
### Decision 1: spec-only change, zero code touch
Rationale: the source already implements every requirement to be folded. Any diff outside `openspec/specs/crypto/` and `openspec/specs/crypto-keys/` would expand scope into a re-audit of `restore-crypto-opinionated-layer`'s archive. By constraining the change to spec text plus deletion of the stub, the review surface is bounded and the risk of accidentally re-implementing already-shipped logic is zero.
Alternative considered: a combined "spec restore + missing-test backfill" change. Rejected — coverage is already at 100% on this package per the gate, so any "missing test" would be a requirement that was never shipped, which makes it a new feature change, not a fold.
### Decision 2: fold by direct copy, not by reference
Each folded requirement's text lives in `openspec/specs/crypto/spec.md` as-written. We do **not** add `See archived umbrella` cross-references. Reasons:
1. Live specs are the contract. A live spec that delegates to an archived spec for its central invariants is dead-on-arrival when the archive is read in a different context.
2. `openspec validate` does not traverse archive paths.
3. The umbrella contained more than this change is folding (e.g. `Domain-separation tag on every signed payload` cross-cuts requirements that are out of this change's scope). Direct-copy lets us select cleanly.
### Decision 3: every folded requirement carries `Verified by:`
`Verified by: <test-file-path>::"<test name>"` is mandatory on every new requirement. The format is the same one already used in newer parts of `openspec/specs/crypto/spec.md` (e.g. lines 311, 367). The validation step in `tasks.md` greps each cited test file for the exact `it(` / `test(` string before the change is allowed to archive.
Rationale: a folded requirement without a `Verified by:` pointer is indistinguishable from a forward-looking spec, which is exactly the failure mode that produced the current contradictory corpus.
### Decision 4: requirements are folded at umbrella granularity, not split
The umbrella named 14 requirements that are missing from the live spec. Each becomes a single requirement here, with the same name. We do not split a single umbrella requirement into multiple smaller ones (e.g. one per scenario). Reasons:
1. Bigger requirements with multiple `#### Scenario:` blocks are the existing house style in `openspec/specs/crypto/spec.md`.
2. Splitting would reformat the umbrella's authorial intent without adding signal.
3. A single requirement per umbrella concept makes the diff between umbrella archive and live spec trivially auditable.
### Decision 5: delete `openspec/specs/crypto-keys/spec.md`, do not rewrite it
The stub describes `BaseKeySchema` device keys (`{s, v, m, u, d, o}`) for a deleted package. Two alternatives were considered:
a. Rewrite the stub as a `crypto-keys` capability that documents the post-fold "the package no longer exists" reality. Rejected — non-existent capabilities are noise, and `openspec/specs/crypto/spec.md` already asserts non-existence.
b. Move the stub's content (the device-key requirements) into `openspec/specs/crypto/spec.md` under a new requirement. Rejected — the underlying device-key derivation API is **not implemented** on genesis (per `archive/2026-05-03-crypto-completion/tasks.md:60`, the device-key suite is deferred to a `crypto-keys-port` follow-up). Folding it now would re-introduce the same problem this change is fixing: a requirement with no `Verified by:` pointer.
Chosen: delete the stub. If `crypto-keys-port` ever ships, it gets to add its own requirements with its own implementing tests.
### Decision 6: name the requirements verbatim from the umbrella, drop the unbacked ones
The 13 requirement headings will read exactly as they did in the umbrella's `specs/crypto/spec.md`:
1. Eight-slot Keychain with full-width derivation coordinates
2. DerivationLayout registry — pluggable u64 layout
3. Validity windows enforced at every verify call
4. Domain-separation tag on every signed payload
5. Public bundles are parent-signed and carry an issuer reference
6. Chain bundle traversal — concatenated parent-signed links
7. Wire-format schemas exposed and committed
8. Authority — signing abstraction over hierarchical identity
9. Hierarchical identity — Root and Server
10. RevocationList — root-signed CRL
11. AuditLog — append-only Merkle log
12. Packer — encrypt-and-sign envelope
13. ChunkedPacker — per-chunk-signed P2P fragmentation
The umbrella's `Trust-anchor bootstrap` requirement is **dropped** from this fold. Direct inspection of `packages/crypto/crypto/src/` confirms there is no `trust-anchors/` directory, no `loadTrustAnchor()` / `loadTrustAnchorFromEnv()` symbol, and no `SUAVEPLAN_TRUST_ANCHOR_PATH` consumer. Folding the requirement now would create a spec entry whose `Verified by:` pointer cites a test that does not exist — exactly the failure mode this change exists to fix. Trust-anchor bootstrap needs an implementation change first; this change leaves it explicitly out of scope.
The umbrella's `Vault` and `Replay protection via issuedAt + nonce` requirements are **not** added either — their content overlaps with the existing live requirement `Opinionated layer modules — Identity, Revocation, Audit, Packer, Vault` (line 209) plus the replay scenarios that ride on the new `Packer` requirement. Folding them as separate requirements would create cross-cutting duplication. The live `Opinionated layer modules` requirement is left intact as a higher-level overview; the 13 new requirements add the missing per-module detail.
The umbrella's `Wire-format KAT vectors`, `Property-based round-trip coverage`, `OpenTelemetry instrumentation with key-byte redaction`, and `Structured error codes with stable identifiers` requirements are **not** folded either: KAT and property-coverage are implementation gates already enforced by the existing 100%-coverage requirement; OTel redaction is covered by the live "Structured errors never leak secrets" requirement at line 139; structured error codes are covered by the same live requirement. Adding any of them would duplicate.
## Risks / Trade-offs
- [Risk] A `Verified by:` pointer cites a test that does not actually assert what the requirement says. → Mitigation: the validation step in `tasks.md` greps each cited test file for the exact `it(` / `test(` string and reports any miss as a hard failure before archive. A second mitigation: every requirement's scenario block is hand-copied from the umbrella, where each scenario was originally written to map to a planned test name.
- [Risk] The umbrella's wording references symbols (`KEYCHAIN.UNKNOWN_LAYOUT`, `TRUST_ANCHOR.MISMATCH`, `DOMAIN_PUBLIC_KEYCHAIN_V1`) that may have been renamed in source after archive. → Mitigation: `tasks.md` includes a grep step that confirms each cited symbol exists in `packages/crypto/crypto/src/`. A miss either reveals a stale-spec bug (which fails the change) or a stale-source bug (which gets a separate fix).
- [Risk] Folding 14 requirements expands `openspec/specs/crypto/spec.md` substantially, raising the chance of merge conflicts with parallel changes touching `crypto`. → Mitigation: this change is small in absolute terms (~600 lines of spec text) and ships before any other open `crypto` change can land. We do not block on adjacent capabilities.
- [Risk] Removing `openspec/specs/crypto-keys/spec.md` breaks any external doc/link that references it. → Mitigation: a grep over the genesis tree will be run as part of `tasks.md`. If any consumer references the path, the change blocks until those references are updated. Empirically (per the search done while drafting this proposal), only `archive/2026-05-03-crypto-completion/` references `crypto-keys`, and archives are immutable.
- [Trade-off] Spec-only changes are cheap to write and risky to review — they look low-stakes but encode contracts. → Counterweight: every requirement has a verifiable test pointer, so reviewers can run `bunx vitest run <test-file> --reporter=verbose` and confirm the cited scenarios pass. The contract is testable, not just readable.
## Migration Plan
1. **Add deltas**: write `specs/crypto/spec.md` (`## ADDED Requirements` section, 14 entries) and `specs/crypto-keys/spec.md` (`## REMOVED Requirements` section listing every requirement currently in `openspec/specs/crypto-keys/spec.md`). The delta files live under `openspec/changes/crypto-spec-restore-versioning/specs/`.
2. **Validate**: `bun openspec validate crypto-spec-restore-versioning --strict` passes.
3. **Verified-by audit**: for each new requirement, grep its `Verified by:` pointer against the cited test file and assert match. Block on any miss.
4. **Symbol audit**: grep each cited symbol (`DERIVATION_LAYOUT_V1`, `DOMAIN_VAULT_V1`, `PUBLIC_BUNDLE_MAGIC`, etc.) against `packages/crypto/crypto/src/` and assert presence.
5. **External-reference audit**: grep `crypto-keys` against the live tree (excluding `openspec/archive/`) and assert no consumer breaks.
6. **Apply**: fold the deltas into `openspec/specs/crypto/spec.md`; delete `openspec/specs/crypto-keys/`; archive `openspec/changes/crypto-spec-restore-versioning/` to `openspec/archive/<date>-crypto-spec-restore-versioning/`.
7. **Post-archive validation**: `bun openspec validate --strict` passes for the entire spec corpus.
**Rollback strategy**: revert the single archive commit. The change is one commit by design.
## Open Questions
- Should `Domain-separation tag on every signed payload` be folded here, or already covered by the existing live "Structured errors never leak secrets" + "Opinionated layer modules" pair? Default per Decision 6: fold it. Reviewer can pull it if the duplication is judged worse than the explicit mention.
- Should `Wire-format schemas exposed and committed` reference the KAT-fixture directory `packages/crypto/crypto/test-fixtures/wire-format/` if it exists, or stay symbol-only? Default: stay symbol-only; if a `test-fixtures/` dir is found during the symbol-audit step, add a scenario that asserts byte-stability against it.

View File

@@ -0,0 +1,43 @@
## Why
`@suaveplan/crypto` ships with versioned-key vocabulary fully implemented in source — `DerivationCoordinate` carries `masterVersion u16 ‖ userId u32 ‖ depth u8 ‖ slotVersion u8` (`packages/crypto/crypto/src/derivation/{coordinate,layouts}/`), the eight-slot `Keychain` is built on it (`packages/crypto/crypto/src/keychain/`), and `RootIdentity` / `ServerIdentity` / `RevocationList` reference a server `version` (`packages/crypto/crypto/src/{identity,revocation}/`). The umbrella `restore-crypto-keychain-packer-umbrella` was archived on 2026-05-03 with 20 requirements covering this surface, but its spec deltas never folded into `openspec/specs/crypto/spec.md`. The live spec lost `DerivationCoordinate`, `DerivationLayout`, `masterVersion`, `slotVersion`, `BaseKeySchema`, `Authority`, hierarchical identity, revocation, audit log, packer, replay protection, KAT vectors, trust-anchor bootstrap, and OTel redaction as named requirements. The only "version" terms that survived are the password-blob header byte and a stray `rollServer increments version` scenario with no defining requirement. To compound it, `openspec/specs/crypto-keys/spec.md` exists as a stub describing the deleted `@suaveplan/crypto-keys` package, contradicting the live `crypto/spec.md:204-207` requirement that the package be deleted (zero matches in `packages/`). The corpus is internally inconsistent and the central crypto invariant — every key carries `(masterVersion, slotVersion)` — is undocumented at the requirement layer, so no `Verified by:` chain pins the existing tests to a spec.
## What Changes
- **MODIFIED `crypto` capability**: fold the 13 missing umbrella requirements into `openspec/specs/crypto/spec.md` with `Verified by:` pointers to existing tests in `packages/crypto/crypto/src/{derivation,keychain,identity,revocation,audit-log,packer,chunked-packer,bundles,wire-format,domain-tags,authority}/`. New requirements:
- Eight-slot Keychain with full-width derivation coordinates (`master / encrypt / sign / kx / salt / hash / stream / device`)
- DerivationLayout registry — pluggable u64 layout, default `DERIVATION_LAYOUT_V1` (`masterVersion u16 ‖ userId u32 ‖ depth u8 ‖ slotVersion u8`)
- Validity windows enforced at every verify call (`notBefore` / `notAfter` per slot, injectable Clock)
- Domain-separation tag on every signed payload (`DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`, `DOMAIN_PACKER_V1`, `DOMAIN_CHUNK_V1`, `DOMAIN_CRL_V1`, `DOMAIN_AUDIT_V1`)
- Public bundles are parent-signed and carry an issuer reference (`(masterVersion, userId, depth, slotVersion)`)
- Chain bundle traversal — concatenated parent-signed links
- Wire-format schemas exposed and committed (`PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `*_FORMAT_VERSION`)
- Authority — signing abstraction over hierarchical identity
- Hierarchical identity — Root and Server (`createRoot`, `createServer`, `rollServer` with `version + 1`, `revokeServer`)
- RevocationList — root-signed CRL keyed by `(serverId, version)`
- AuditLog — append-only Merkle log
- Packer — encrypt-and-sign envelope with replay protection (`issuedAt + nonce`)
- ChunkedPacker — per-chunk-signed P2P fragmentation
- **NOT folded — deferred to a follow-up implementation change**: the umbrella's `Trust-anchor bootstrap` requirement specified `src/trust-anchors/` with `loadTrustAnchor()` / `loadTrustAnchorFromEnv()` / `SUAVEPLAN_TRUST_ANCHOR_PATH`. That directory and surface do not exist on genesis. Folding the requirement here would re-introduce the fail-mode this change is fixing (a requirement with no `Verified by:` pointer). It needs its own implementation change.
- **REMOVED `crypto-keys` capability**: delete `openspec/specs/crypto-keys/spec.md` (the stub describing the deleted `@suaveplan/crypto-keys` package). The live `crypto/spec.md` requirement "Keypair, Keychain storage, BIP39, and legacy Vault live inside `@suaveplan/crypto`" already asserts the package is gone; the orphan stub contradicts it.
- This change is **spec-only** — no source code changes, no test changes. Every new requirement has its `Verified by:` pointer resolve to an already-passing test file. If a requirement cannot be backed by an existing test, that requirement does not ship in this change (it gets a follow-up implementation change).
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `crypto`: add 14 requirements covering versioned-key derivation, hierarchical identity, revocation, audit, packer envelopes, wire format, and trust-anchor bootstrap; no requirements modified or removed.
- `crypto-keys`: remove the entire spec file. The capability does not exist on genesis (the package was deleted as part of `restore-crypto-fold-keys-vault`); the stub spec is an artifact of an incomplete archive.
## Impact
- **Affected specs**: `openspec/specs/crypto/spec.md` (additions only), `openspec/specs/crypto-keys/spec.md` (deleted).
- **Affected code**: none. Source already implements every requirement being folded; this change documents what already ships.
- **Affected tests**: none added, none removed. New `Verified by:` pointers reference existing test files at `packages/crypto/crypto/src/{derivation,keychain,identity,revocation,audit-log,packer,chunked-packer,bundles,wire-format}/`.
- **Conformance impact**: `bun openspec validate crypto-spec-restore-versioning --strict` must pass. The post-archive live spec must also pass `openspec validate --strict` and the existing `scripts/openspec-conformance.ts` allowlist gate.
- **Downstream**: future changes that touch `Keychain`, `Authority`, `RevocationList`, `AuditLog`, `Packer`, or `ChunkedPacker` will now have a stable requirement surface to delta against, instead of free-form code drift.
- **Risk**: low — no behaviour changes. The risk is a `Verified by:` pointer resolving to a test that does not actually cover the requirement; mitigated by the validation step in `tasks.md` that greps each cited test file for the asserted scenario before the change is archived.

View File

@@ -0,0 +1,37 @@
# Capability Spec Delta: crypto-keys (crypto-spec-restore-versioning)
**Status**: PROPOSED — capability removal
> The `@suaveplan/crypto-keys` package was deleted from genesis as part of the archived change `restore-crypto-fold-keys-vault` (folded into `@suaveplan/crypto`). The live spec at `openspec/specs/crypto/spec.md:204-207` already asserts the package's absence (`grep -rln "@suaveplan/crypto-keys" packages/` MUST return zero matches). This delta removes the orphan stub `openspec/specs/crypto-keys/spec.md` so the corpus stops self-contradicting.
## REMOVED Requirements
### Requirement: Device key creation
**Reason**: The `@suaveplan/crypto-keys` package was deleted from the genesis monorepo (`packages/crypto/crypto-keys/` does not exist). The device-key surface (`createDeviceKey`, `BaseKeySchema` device metadata) was deferred from `crypto-completion` to a follow-up `crypto-keys-port` change that has not been opened. Documenting the requirement now would leave a forward-looking spec with no implementing test, exactly the failure mode this parent change exists to fix.
**Migration**: No migration is needed because the package and its surface never existed on genesis. If `crypto-keys-port` is ever opened, that change will re-introduce its own requirements with backing tests; until then, the requirement does not belong in the live spec corpus.
### Requirement: Device key derivation from master
**Reason**: Same as above — the `deriveDeviceKeyFromMaster` surface was deferred and is not implemented anywhere in `packages/`.
**Migration**: None.
### Requirement: Device UUID generation
**Reason**: Same as above — `generateDeviceUUID` is not implemented in any genesis package.
**Migration**: None.
### Requirement: Device metadata extraction and comparison
**Reason**: Same as above — `extractDeviceMetadata`, `compareDeviceKeys`, `isValidDeviceKey` are not implemented in any genesis package.
**Migration**: None.
### Requirement: generateDerivedDeviceId back-compat alias
**Reason**: Same as above — `generateDerivedDeviceId` is not implemented in any genesis package.
**Migration**: None.

View File

@@ -0,0 +1,509 @@
# Capability Spec Delta: crypto (crypto-spec-restore-versioning)
**Package**: `@suaveplan/crypto`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. Each pointer is verified against an existing test in `packages/crypto/crypto/src/` before this change archives. (See `openspec/AGENTS.md` Section 13 and the parent change's `tasks.md` step 4.)
## ADDED Requirements
### Requirement: Eight-slot Keychain with full-width derivation coordinates
`@suaveplan/crypto` SHALL define a `Keychain` type representing a coherent identity composed of eight default slots, each addressed by a `DerivationCoordinate` and carrying `{algorithm, notBefore, notAfter, secretKey?, publicKey?}`. The eight default slot purposes are: `master`, `encrypt`, `sign`, `kx`, `salt`, `hash`, `stream`, `device` — each represented as a 4-byte ASCII purpose tag (`"mast"`, `"encr"`, `"sign"`, `"kx "`, `"salt"`, `"hash"`, `"strm"`, `"dev "`).
A `DerivationCoordinate` carries:
- **`purpose`** — 4-byte ASCII (the slot type)
- **`application`** — 4-byte ASCII (which product/component, e.g. `"chat"`, `"vlt "`, `"genr"` default)
- **`layoutId`** — `u8` selecting which `DerivationLayout` interprets the u64 fields below
- Layout-specific fields filling the remaining 64 bits (default `DERIVATION_LAYOUT_V1`: `masterVersion u16`, `userId u32`, `depth u8`, `slotVersion u8`)
The `(purpose, application)` pair maps onto libsodium's 8-byte `ctx`; the four layout fields pack into libsodium's `subkey_id` (u64). Together they form the 16-byte derivation namespace this package consumes. The `algorithm` field is required and is one of `ed25519`, `ecdsa-p256`, `x25519`, `xchacha20-poly1305`, `aes-256-gcm`, `chacha20-poly1305`, `argon2id`, `hkdf-sha256` depending on slot purpose.
#### Scenario: deterministic generation from a master and coordinate set
- **WHEN** `generateKeychain` is called twice with the same master key and the same coordinate inputs
- **THEN** both results are byte-equal across every slot
#### Scenario: userId beyond the legacy 4-bit ceiling is supported
- **WHEN** a caller invokes `generateKeychain({ userId: 1_000_000, ... })`
- **THEN** the call succeeds — the default `DERIVATION_LAYOUT_V1` allocates 32 bits to `userId`, supporting up to ~4.3 billion distinct users per `(masterVersion, application)` pair (regression-test against the legacy 4-bit ceiling)
#### Scenario: per-purpose algorithm overrides honored
- **WHEN** `generateKeychain` is called with a per-purpose algorithm override map
- **THEN** the resulting slot's `algorithm` field reflects the override and the slot's key material has the override's key length
#### Scenario: extract public-only keychain
- **WHEN** `extractPublicKeychain(keychain)` is called on a keychain with both secret and public material
- **THEN** the returned keychain contains only `publicKey` per slot; every `secretKey` field is `undefined` and the result is a deep clone with no shared buffers
**Verified by:** `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces all eight default slots from a deterministic master"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"deterministic: same master + coords yield byte-equal keychain"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"userId beyond the legacy 4-bit ceiling round-trips"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"honours per-purpose algorithm override"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"drops every secretKey and copies publicKey buffers"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces a deep clone with no shared buffers"`
---
### Requirement: DerivationLayout registry — pluggable u64 layout
`@suaveplan/crypto` SHALL ship a `DerivationLayout` registry mapping `layoutId: u8` to a `BitSchema` describing how the 64-bit `subkey_id` half of the derivation namespace is structured. The default `DERIVATION_LAYOUT_V1` (id 0) splits as `{masterVersion: u16, userId: u32, depth: u8, slotVersion: u8}`. Consumers MAY register additional layouts via `defineDerivationLayout(id, schema)`.
The `layoutId` byte is recorded in every wire-format payload that carries a `DerivationCoordinate`. Verifiers look up the layout by id at parse time; an unknown layout id is a parse failure with `KEYCHAIN.UNKNOWN_LAYOUT`. A custom layout whose `byteSize` is not exactly 8 is rejected at registration with `KEYCHAIN.LAYOUT_TOO_WIDE`. Re-registering an existing id is rejected with `KEYCHAIN.LAYOUT_ALREADY_REGISTERED`.
This is what makes the package future-proof: adding a v2 layout for a new product is non-breaking; old vaults still parse under their original layout id; the verifier dispatches by id.
#### Scenario: default v1 layout fills exactly 64 bits
- **WHEN** the `DERIVATION_LAYOUT_V1` schema's `byteSize` is checked
- **THEN** the schema reports exactly 8 bytes (16 + 32 + 8 + 8 = 64 bits) — required to fit libsodium's `subkey_id`
#### Scenario: registering a custom 8-byte layout
- **WHEN** `defineDerivationLayout(id, customSchema)` is called with a schema whose `byteSize === 8`
- **THEN** the layout is registered and round-trips a typical record under that id
#### Scenario: oversized custom layout is rejected
- **WHEN** `defineDerivationLayout(id, oversizedSchema)` is called with a schema whose `byteSize > 8`
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE`
#### Scenario: undersized custom layout is rejected
- **WHEN** `defineDerivationLayout(id, undersizedSchema)` is called with a schema whose `byteSize < 8`
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE`
#### Scenario: re-registration is rejected
- **WHEN** `defineDerivationLayout(id, schema)` is called for an id already in the registry
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_ALREADY_REGISTERED`
#### Scenario: parsing an unknown layout id fails distinctly
- **WHEN** a wire-format payload's `layoutId` is not registered
- **THEN** parsing throws `CryptoError` code `KEYCHAIN.UNKNOWN_LAYOUT`, naming the unregistered id
**Verified by:** `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"has byteSize === 8 (fits libsodium subkey_id)"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"v1 layout is registered at id 0 by default"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"registers a new 8-byte custom layout"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout exceeding 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout smaller than 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects re-registration with LAYOUT_ALREADY_REGISTERED"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"throws UNKNOWN_LAYOUT for unregistered id"`
---
### Requirement: Validity windows enforced at every verify call
`@suaveplan/crypto` SHALL enforce per-slot `notBefore` / `notAfter` validity windows at every signature verification, AEAD decryption, and key-agreement operation. The current time is read from an injectable `Clock` provided at construction; production paths MUST NOT call `Date.now()` directly inside the keychain / authority / packer / bundle modules.
A keychain slot whose validity window has expired or has not yet begun MUST cause the operation to fail with `CryptoError` code `KEYCHAIN.NOT_YET_VALID` or `KEYCHAIN.EXPIRED`. Strict mode is the default; relaxed mode (used only for archival inspection) is opt-in via an explicit `acceptExpired: true` option, and MUST NOT bypass `NOT_YET_VALID`.
#### Scenario: signing with an expired slot fails
- **WHEN** an `Authority` is asked to sign with a slot whose `notAfter` is before `clock.now()`
- **THEN** the call throws `CryptoError` code `KEYCHAIN.EXPIRED`
#### Scenario: verifying against a not-yet-valid slot fails
- **WHEN** a verify path is called against a public keychain slot whose `notBefore` is after `clock.now()`
- **THEN** the call throws `CryptoError` code `KEYCHAIN.NOT_YET_VALID`
#### Scenario: clock exactly at notBefore or notAfter passes
- **WHEN** a verify call observes `clock.now() === notBefore` or `clock.now() === notAfter`
- **THEN** the verify succeeds (the window is inclusive at both endpoints)
#### Scenario: relaxed mode allows reading after notAfter only
- **WHEN** an inspection helper is called with `acceptExpired: true` against a slot whose `notAfter` is past
- **THEN** the call succeeds; the same call against a `NOT_YET_VALID` slot still throws
**Verified by:** `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.NOT_YET_VALID before notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.EXPIRED after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired allows reading after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired does NOT bypass NOT_YET_VALID"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"`
---
### Requirement: Domain-separation tag on every signed payload
`@suaveplan/crypto` SHALL prepend a domain-separation tag to every payload before passing it to a signing primitive. Tags are typed as branded `string` constants exported from `domain-tags/`: `DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`, `DOMAIN_PACKER_V1`, `DOMAIN_CHUNK_V1`, `DOMAIN_CRL_V1`, `DOMAIN_AUDIT_V1`, `DOMAIN_SLSA_BUILDER_V1`, plus the bitpacker-aware tags `DOMAIN_BITPACKER_SIGNED_V1`, `DOMAIN_BITPACKER_HASHCHAIN_V1`, `DOMAIN_BITPACKER_MULTISIGN_V1`. The `ALL_DOMAIN_TAGS` registry MUST contain exactly the v1 set.
Verifiers MUST require the same tag at verify time; a payload signed under tag `A` MUST fail verification if presented to a verifier expecting tag `B` (cross-protocol replay defence). The tag MUST be part of the signed bytes, not metadata: an attacker swapping the tag prefix without re-signing fails verification.
`encodeDomainTag(tag)` SHALL produce ASCII bytes followed by a `0x00` terminator; `concatTagAndPayload(tag, payload)` SHALL concatenate the encoded tag and the payload bytes.
#### Scenario: registry size and uniqueness
- **WHEN** `ALL_DOMAIN_TAGS` is enumerated
- **THEN** it contains exactly 10 tags in v1, each a unique string
#### Scenario: encoded tag is ASCII-plus-NUL
- **WHEN** `encodeDomainTag(tag)` is called for any registered tag
- **THEN** the result is the ASCII bytes of the tag followed by a single `0x00` byte
#### Scenario: distinct tags produce distinct signed inputs over the same payload
- **WHEN** two different domain tags are concatenated with the same payload
- **THEN** the resulting byte sequences differ
#### Scenario: cross-protocol replay rejected
- **WHEN** an envelope packed under `DOMAIN_VAULT_V1` is presented to an `Unpacker` configured for `DOMAIN_PACKER_V1`
- **THEN** the call throws a `CryptoError` whose code identifies a domain mismatch (no plaintext is returned)
**Verified by:** `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"ALL_DOMAIN_TAGS contains 10 tags in v1"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"each tag is a unique string"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces ASCII bytes followed by 0x00 terminator"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces different bytes for different domains over the same payload"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"`
---
### Requirement: Public bundles are parent-signed and carry an issuer reference
`@suaveplan/crypto` SHALL provide `packPublicBundle(keychain, parentAuthority, options)` that emits a bit-packed binary bundle in the wire format below, with a trailing 64-byte ed25519 signature produced by `parentAuthority.sign(DOMAIN_PUBLIC_KEYCHAIN_V1, "sign", bundleBytes)`. The signature covers everything in the bundle including the embedded `issuerRef` — swapping the issuer reference invalidates the signature.
`unpackPublicBundle(bytes, parentSignPublicKey)` verifies the trailing signature against the supplied parent public sign key, returning the parsed bundle and the verified `issuerRef`. A `parentSignPublicKey` of `null` selects self-verify mode, valid only for root identities (the bundle's own embedded `sign` slot is used).
The wire format identifier is `PUBLIC_BUNDLE_MAGIC` (4 ASCII bytes), `PUBLIC_BUNDLE_FORMAT_VERSION` is `1` in v1, and the header byte size is committed as `PUBLIC_BUNDLE_HEADER_BYTE_SIZE`. The slot record layout is committed as `KEY_SLOT_PUBLIC_BYTE_SIZE` and `KEY_SLOT_PUBLIC_BYTES = 17 + 48 = 65` bytes.
For a root bundle the issuer `(masterVersion, userId, depth, slotVersion)` equals the subject's, and the parent authority is the keychain itself (self-signing). Tampered bundle bytes (slot data, signature, magic, version, slotCount, length) MUST cause parse / verify to fail.
#### Scenario: self-signed bundle round-trips and self-verifies
- **WHEN** `packPublicBundle(rootKeychain, rootAuthority)` is called and the bundle is then verified in self-verify mode
- **THEN** the call succeeds and the parsed bundle's slots match the input keychain
#### Scenario: parent-signed bundle verifies against parent's sign pubkey
- **WHEN** `packPublicBundle(serverKeychain, rootAuthority)` is called and the result is verified against the root's public sign key
- **THEN** the call succeeds; verification against any non-root public key fails
#### Scenario: tampered slot bytes fail verification
- **WHEN** any byte of a packed bundle's slot section is mutated
- **THEN** verification fails
#### Scenario: tampered signature fails verification
- **WHEN** any byte of the trailing 64-byte signature is mutated
- **THEN** verification fails
#### Scenario: parse rejects malformed payloads
- **WHEN** the bundle has bad magic, wrong format version, bad slotCount, a too-short payload, or extra trailing bytes
- **THEN** `unpackPublicBundle` throws a parse error
**Verified by:** `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"self-signed bundle round-trips and self-verifies"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"parent-signed (third-party) bundle verifies against parent's sign pubkey"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered slot bytes fail verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered signature fails verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad magic fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"wrong format version fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad slotCount fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"short payload fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"extra bytes fails parse"`
---
### Requirement: Chain bundle traversal — concatenated parent-signed links
`@suaveplan/crypto` SHALL provide a `ChainBundle` wire format and a `verifyChain(bundle, trustAnchor, clock, isRevoked?)` walker. A `ChainBundle` is a header followed by a concatenation of `PublicBundle` records ordered leaf-first. The wire format identifier is `CHAIN_BUNDLE_MAGIC`, the format version is `CHAIN_BUNDLE_FORMAT_VERSION` (currently `1`), the header byte size is `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, and `linkCount` is bounded by `MAX_CHAIN_LINKS` (16).
The walker MUST: verify each link's signature against the next link's `sign` slot (or against `trustAnchor` for the final link), reject expired or not-yet-valid slots via the configured `Clock`, reject revoked links via the optional `isRevoked` predicate, reject `linkCount === 0`, reject `linkCount > MAX_CHAIN_LINKS`, reject mismatched `issuerRef` between adjacent links, reject bad magic / version / length / linkCount overflows at parse time. The bundle wire format MUST be byte-identical to the v1 snapshot.
#### Scenario: 3-link chain verifies end-to-end
- **WHEN** a chain bundle is built as `[subService, server, root]` (each parent-signed by the next)
- **THEN** `verifyChain` succeeds; the parsed chain has length 3
#### Scenario: tampered link fails verification at that link
- **WHEN** any byte of a non-leaf link is mutated
- **THEN** `verifyChain` rejects
#### Scenario: revoked link fails via predicate
- **WHEN** the `isRevoked` predicate returns true for an intermediate link
- **THEN** `verifyChain` rejects
#### Scenario: expired sign slot fails
- **WHEN** any link's `sign` slot has `notAfter < clock.now()`
- **THEN** `verifyChain` rejects with `KEYCHAIN.EXPIRED`
#### Scenario: mismatched issuer coord fails
- **WHEN** an intermediate link's `issuerRef` does not equal the next link's signing-slot coordinate
- **THEN** `verifyChain` rejects
#### Scenario: link count bounds enforced
- **WHEN** packing requests `0` links or more than `MAX_CHAIN_LINKS`
- **THEN** packing throws
#### Scenario: parse rejects malformed payloads
- **WHEN** the bundle has bad magic, bad version, out-of-range linkCount, length mismatch, or is too short
- **THEN** `parseChainBundle` throws
#### Scenario: wire format is byte-identical to v1 snapshot
- **WHEN** a fixed-input chain is packed and compared against the committed v0x0001 snapshot
- **THEN** the bytes match exactly
**Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packs and parses a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifies a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects tampered link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects revoked link via predicate"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects expired sign slot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects mismatched issuer coord"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects 0-link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects > MAX_CHAIN_LINKS"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects out-of-range linkCount"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects length mismatch"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"`
---
### Requirement: Wire-format schemas exposed and committed
`@suaveplan/crypto` SHALL expose a `wire-format` barrel (`@suaveplan/crypto/wire-format`) that re-exports every committed wire-format identifier and byte-size constant from its owning module:
- `PUBLIC_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `PUBLIC_BUNDLE_HEADER_BYTE_SIZE` (from `bundles/public-bundle/`)
- `CHAIN_BUNDLE_MAGIC`, `CHAIN_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, `MAX_CHAIN_LINKS` (from `bundles/chain-bundle/`)
- `V1_COORDINATE_BYTE_SIZE`, `COORDINATE_HEADER_BYTE_SIZE` (from `derivation/coordinate/`)
- `KEY_SLOT_META_PRIVATE_SCHEMA`, `KEY_SLOT_META_PUBLIC_SCHEMA`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE` (from `keychain/key-slot/`)
Each schema's byte-size is committed as a constant: `KEY_SLOT_PRIVATE_BYTE_SIZE === 17 + 80 === 97` and `KEY_SLOT_PUBLIC_BYTE_SIZE === 17 + 48 === 65`. Any field-width change MUST update both the constant and its consumer test, providing a loud signal for wire-format regression.
#### Scenario: barrel re-exports every committed identifier
- **WHEN** a consumer imports from `@suaveplan/crypto/wire-format`
- **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_FORMAT_VERSION`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `MAX_CHAIN_LINKS` are all importable
#### Scenario: key-slot byte sizes are exactly the committed constants
- **WHEN** the key-slot byte-size test runs
- **THEN** the private slot is 97 bytes and the public slot is 65 bytes
**Verified by:** `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"private slot is 17 + 80 = 97 bytes"` AND `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"public slot is 17 + 48 = 65 bytes"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"`
---
### Requirement: Authority — signing abstraction over hierarchical identity
`@suaveplan/crypto` SHALL define an `Authority` interface that signs payloads with a named slot under a domain tag, exposing `sign(domain, slotPurpose, payload, signal?): Promise<Uint8Array>` and `dispose(): void`. The interface is the only signing surface exposed to callers outside the package; raw `secretKey: Uint8Array` references are not passed across package boundaries.
V1 ships an `InMemoryAuthority` backend that holds an unlocked `Keychain` and signs from its slots. The interface is shaped so future backends (`YubiKeyAuthority`, `TpmAuthority`, `ThresholdAuthority`) can be substituted without changes to callers.
`Authority` MUST: produce signatures that verify under the corresponding public sign slot, fail under a different domain tag, throw `KEYCHAIN.UNKNOWN_PURPOSE` for unknown slots, throw `KEYCHAIN.SLOT_INVALID` when signing with a non-sign slot, throw `KEYCHAIN.EXPIRED` when the slot's `notAfter` is past, throw `AUTHORITY.REVOKED` when an `isRevoked` predicate matches, throw `AUTHORITY.DISPOSED` after `dispose()`, throw `AUTHORITY.ABORTED` when invoked with an already-aborted signal. `dispose()` MUST zeroise held secret material and MUST be idempotent.
#### Scenario: sign and verify under matching slot and domain
- **WHEN** `authority.sign(domain, "sign", payload)` is called
- **THEN** the returned signature verifies against the public sign slot's key under the same domain
#### Scenario: signature does not verify under a different domain tag
- **WHEN** the signature is verified under a different domain tag
- **THEN** verification returns false
#### Scenario: unknown slot purpose rejected
- **WHEN** `authority.sign(domain, "bogus", payload)` is called
- **THEN** the call throws `KEYCHAIN.UNKNOWN_PURPOSE`
#### Scenario: signing with a non-signing slot rejected
- **WHEN** `authority.sign(domain, "encr", payload)` is called
- **THEN** the call throws `KEYCHAIN.SLOT_INVALID`
#### Scenario: dispose zeroises and locks
- **WHEN** `authority.dispose()` is called and `authority.sign(...)` is then invoked
- **THEN** post-dispose secret-key buffers contain zeros and the call throws `AUTHORITY.DISPOSED`; calling `dispose()` again is a no-op
#### Scenario: aborted signal cancels signing
- **WHEN** `authority.sign(..., signal)` is called with an already-aborted signal
- **THEN** the call throws `AUTHORITY.ABORTED`
#### Scenario: revoked slot fails before signing
- **WHEN** an `isRevoked` predicate returns true for the slot and `authority.sign(...)` is called
- **THEN** the call throws `AUTHORITY.REVOKED` before any signing primitive executes
**Verified by:** `packages/crypto/crypto/src/authority/authority.test.ts::"signs with the sign slot and verifies"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"signature does NOT verify under a different domain tag"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.UNKNOWN_PURPOSE for an unknown slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.SLOT_INVALID when signing with a non-sign slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.DISPOSED after dispose"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose zeroises secret-key buffers"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose is idempotent"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.ABORTED when signal already aborted"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.REVOKED when isRevoked predicate matches"`
---
### Requirement: Hierarchical identity — Root and Server
`@suaveplan/crypto` SHALL provide `createRoot(options)`, `createServer(rootAuthority, options)`, `rollServer(rootAuthority, server)`, and `revokeServer(rootAuthority, server, reason)`. A `RootIdentity` is a Keychain whose `sign` slot is the trust anchor for everything beneath it. A `ServerIdentity` is a Keychain whose public bundle has been signed by the root's sign slot under `DOMAIN_PUBLIC_KEYCHAIN_V1`.
`rollServer` MUST produce a new `ServerIdentity` whose version is one greater than the prior, emit a record of the prior version (used by `RevocationList`), and accept missing optional `rng`. `revokeServer` MUST return a revocation entry without producing a new identity. `createRoot` and `createServer` MUST honour optional `notBefore`, `notAfter`, and `masterKey`. `createRoot` and `createServer` MUST accept missing optional `rng`. Nested servers (depth ≥ 2) MUST be traversable via the chain-bundle walker.
#### Scenario: createRoot produces a self-verifying identity
- **WHEN** `createRoot({...})` is called
- **THEN** the result's public bundle verifies in self-verify mode
#### Scenario: createServer is signed under root
- **WHEN** `createServer(rootAuthority, ...)` is called
- **THEN** the server's public bundle verifies against the root's public sign key
#### Scenario: rollServer increments version and emits prior
- **WHEN** `rollServer(rootAuthority, server@N)` is called
- **THEN** the result is `server@(N+1)` and a `priorVersion === N` record is emitted
#### Scenario: revokeServer returns an entry without re-issuing
- **WHEN** `revokeServer(rootAuthority, server, reason)` is called
- **THEN** the call returns a revocation entry and no new server identity
#### Scenario: optional notBefore, notAfter, masterKey honoured
- **WHEN** `createRoot` or `createServer` is called with explicit `notBefore` / `notAfter` / `masterKey`
- **THEN** the resulting identity's slots reflect those inputs
#### Scenario: optional rng accepted
- **WHEN** `createRoot`, `createServer`, or `rollServer` is called without a custom `rng`
- **THEN** the call succeeds
#### Scenario: nested server depth-2 builds a 3-link chain
- **WHEN** a sub-service is issued under a server that was issued under a root
- **THEN** a 3-link chain bundle is constructible and verifies against the root anchor
**Verified by:** `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot produces a self-verifying root identity"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer is signed under root"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer increments version + emits priorVersion"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"revokeServer returns an entry without re-issuing"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot honours optional notBefore/notAfter and masterKey"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer honours optional notBefore/notAfter"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer accepts no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot/createServer accept no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"nested server (depth 2) builds a 3-link chain"`
---
### Requirement: RevocationList — root-signed CRL
`@suaveplan/crypto` SHALL provide a `RevocationList` type representing an append-only list of revocation entries, each entry signed by the root's sign slot under `DOMAIN_CRL_V1`. Entries are immutable: any past entry's bytes are part of the signed payload; mutation invalidates the list signature.
The list exposes `append(entry, rootAuthority)`, `verify(rootPublicKey)`, and `check(ref): boolean`. `check` MUST report true for revoked refs and false otherwise.
#### Scenario: append then verify succeeds
- **WHEN** an entry is appended with `rootAuthority` and `verify(rootPublicKey)` is called
- **THEN** verification returns true
#### Scenario: mutating a past entry invalidates the list
- **WHEN** any byte of any past entry is mutated and `verify(rootPublicKey)` is called
- **THEN** verification returns false
#### Scenario: check reports per-entry correctly
- **WHEN** a server identity at a given version is appended and `check` is queried for that ref vs a different version
- **THEN** `check` returns true for the appended ref and false for the other
**Verified by:** `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"append then verify succeeds"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"mutating a past entry invalidates the list"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"check returns true for revoked refs and false otherwise"`
---
### Requirement: AuditLog — append-only Merkle log
`@suaveplan/crypto` SHALL provide an `AuditLog` type recording every issue / roll / revoke event, structured as an append-only Merkle tree. The current Merkle root is signed by the root's sign slot under `DOMAIN_AUDIT_V1`. Any modification to a past entry changes the Merkle root and invalidates the signature.
The log exposes `append(event, rootAuthority)`, `signRoot(rootAuthority)`, `verify(rootPublicKey)`, `proveInclusion(index)`, and `verifyInclusion(index, proof, expectedRoot)`. The Merkle construction MUST handle odd leaf counts by duplicating the last leaf at each level. `signRoot` over an empty log MUST produce a documented zero root. `eventCount` MUST track appends.
#### Scenario: append then verify succeeds
- **WHEN** an event is appended and the root signature is verified against the root public key
- **THEN** verification returns true
#### Scenario: tampering with a past entry invalidates the root
- **WHEN** any byte of any past event is mutated
- **THEN** signature verification returns false
#### Scenario: inclusion proof verifies
- **WHEN** an event is appended and `proveInclusion` is requested for it
- **THEN** the proof verifies under the signed root
#### Scenario: inclusion proof for out-of-range index throws
- **WHEN** `proveInclusion(index)` is called with an out-of-range index
- **THEN** the call throws
#### Scenario: verifyInclusion rejects wrong / mis-sized expected roots
- **WHEN** `verifyInclusion(index, proof, expectedRoot)` is called with an expected root that is wrong or of mismatched length
- **THEN** the call returns false
#### Scenario: odd leaf count handled
- **WHEN** the log has an odd number of leaves and the last leaf is duplicated for Merkle construction
- **THEN** inclusion proofs still verify
#### Scenario: empty log signRoot produces zero root
- **WHEN** `signRoot` is called on an empty log
- **THEN** the produced root is the documented zero value
#### Scenario: verify fails with a wrong sign slot
- **WHEN** the log is verified against an unrelated public sign key
- **THEN** verification returns false
#### Scenario: eventCount tracks appends
- **WHEN** `n` events are appended
- **THEN** `eventCount === n`
**Verified by:** `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"append then root signature verifies"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"tampering with a past entry invalidates the root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof verifies for appended entries"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof for out-of-range index throws"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for a wrong expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for length-mismatched expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof handles odd leaf count (last leaf duplicated)"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"signRoot on empty log produces a zero root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verify fails with a wrong sign slot"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"eventCount tracks appends"`
---
### Requirement: Packer — encrypt-and-sign envelope
`@suaveplan/crypto` SHALL provide a `Packer` / `Unpacker` pair implementing a schema-driven encrypt-and-sign envelope. The envelope MUST: round-trip a value byte-for-byte under matching parties / nonce / domain, reject envelopes packed under a different domain (cross-protocol replay defence), reject envelopes whose `issuedAt` is older than a configurable replay window, reject envelopes whose `nonce` has been seen within a configurable nonce-cache window, validate pack-side nonce length, reject envelopes whose stated ciphertext length overruns the buffer, reject too-short envelopes.
#### Scenario: round-trip preserves the value
- **WHEN** `packer.pack(payload, ...)` is called and the result is `unpacker.unpack(envelope, ...)`-ed
- **THEN** the returned payload byte-equals the original
#### Scenario: replay outside window rejected
- **WHEN** an envelope older than the replay window is presented
- **THEN** `unpacker.unpack` throws
#### Scenario: nonce dedup rejects replay
- **WHEN** the same envelope is presented twice in succession
- **THEN** the first call succeeds; the second throws
#### Scenario: cross-protocol domain tag rejected
- **WHEN** an envelope packed under one domain is presented to an `Unpacker` configured for another
- **THEN** `unpacker.unpack` throws
#### Scenario: invalid pack-side nonce length rejected
- **WHEN** `packer.pack` is called with a nonce of the wrong length
- **THEN** the call throws
#### Scenario: malformed envelope rejected
- **WHEN** an envelope's stated ciphertext length overruns the buffer, OR the envelope is too short
- **THEN** parsing throws
**Verified by:** `packages/crypto/crypto/src/packer/packer.test.ts::"round-trip preserves the value"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"replay outside window rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"nonce dedup rejects replay"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects invalid pack-side nonce length"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects envelope where stated ciphertext length overruns buffer"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects too-short envelope"`
---
### Requirement: ChunkedPacker — per-chunk-signed P2P fragmentation
`@suaveplan/crypto` SHALL provide `ChunkedPacker` / `ChunkedUnpacker` implementing per-chunk signed fragmentation under `DOMAIN_CHUNK_V1`. Reassembly MUST: byte-equal the original payload on round-trip, reject tampered chunks at reassembly, reject missing chunks, reassemble correctly when chunks arrive out of order, reject malformed chunks (too short, wrong `dataLen`, mismatched total), reject empty chunk lists, reject invalid chunk sizes.
#### Scenario: round-trip reassembles byte-identical
- **WHEN** a payload is fragmented into N chunks and reassembled
- **THEN** the reassembled bytes byte-equal the original
#### Scenario: tampered chunk rejected at reassembly
- **WHEN** any byte of a chunk is mutated
- **THEN** reassembly throws
#### Scenario: missing chunk rejected
- **WHEN** any chunk is omitted
- **THEN** reassembly throws
#### Scenario: out-of-order chunks reassemble
- **WHEN** chunks are presented in non-sequential order
- **THEN** reassembly succeeds and byte-equals the original
#### Scenario: malformed chunks rejected
- **WHEN** a chunk is too short, has the wrong `dataLen` field, or has a mismatched total
- **THEN** reassembly throws
#### Scenario: empty list and invalid sizes rejected
- **WHEN** an empty chunk list is presented, or chunking is requested with an invalid size
- **THEN** the call throws
**Verified by:** `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"round-trip reassembles byte-identical"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"tampered chunk rejected at reassembly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"missing chunk rejected"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"out-of-order chunks reassemble correctly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects malformed chunk (too short)"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunk with wrong dataLen field"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunks with mismatched total"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects empty chunks list"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects invalid chunk size"`

View File

@@ -0,0 +1,53 @@
## 1. Pre-flight validation
- [ ] 1.1 Confirm `packages/crypto/crypto-keys/` does not exist on disk; confirm `grep -rln "@suaveplan/crypto-keys" packages/` returns zero matches (excluding `openspec/archive/`)
- [ ] 1.2 Confirm `packages/crypto/crypto/src/derivation/coordinate/coordinate.ts` exports `DerivationCoordinate` carrying `masterVersion` and `slotVersion`
- [ ] 1.3 Confirm `packages/crypto/crypto/src/derivation/layouts/layouts.ts` exports `DERIVATION_LAYOUT_V1` packing `masterVersion u16 ‖ userId u32 ‖ depth u8 ‖ slotVersion u8`
- [ ] 1.4 Confirm `packages/crypto/crypto/src/wire-format/index.ts` re-exports `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `*_FORMAT_VERSION`
- [ ] 1.5 Run `bunx turbo test --filter=@suaveplan/crypto` and confirm green at HEAD
## 2. Verified-by pointer audit
- [ ] 2.1 For each `Verified by:` pointer in `openspec/changes/crypto-spec-restore-versioning/specs/crypto/spec.md`, parse out the `<file-path>::"<test-name>"` pair
- [ ] 2.2 For each pair, run `grep -F "<test-name>" <file-path>` and assert the test name string appears literally inside an `it(` or `test(` call site
- [ ] 2.3 Hard-fail this change if any pointer does not resolve. Adjust the spec text (rename scenario or pointer) and re-run until clean
- [ ] 2.4 Run `bunx vitest run packages/crypto/crypto/src/keychain/keychain.test.ts packages/crypto/crypto/src/keychain/validity/validity.test.ts packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts packages/crypto/crypto/src/derivation/layouts/layouts.test.ts packages/crypto/crypto/src/derivation/coordinate/coordinate.test.ts packages/crypto/crypto/src/domain-tags/domain-tags.test.ts packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts packages/crypto/crypto/src/authority/authority.test.ts packages/crypto/crypto/src/identity/identity.test.ts packages/crypto/crypto/src/revocation/revocation-list.test.ts packages/crypto/crypto/src/audit-log/audit-log.test.ts packages/crypto/crypto/src/packer/packer.test.ts packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts --reporter=verbose` and confirm every test name cited in the spec passes (not skipped, not todo)
## 3. Symbol presence audit
- [ ] 3.1 Run `grep -RnE "DERIVATION_LAYOUT_V1|DERIVATION_LAYOUT_V1_ID" packages/crypto/crypto/src/` and confirm at least one definition site
- [ ] 3.2 Run `grep -RnE "PUBLIC_BUNDLE_MAGIC|CHAIN_BUNDLE_MAGIC|KEY_SLOT_PRIVATE_BYTE_SIZE|KEY_SLOT_PUBLIC_BYTE_SIZE|V1_COORDINATE_BYTE_SIZE|MAX_CHAIN_LINKS" packages/crypto/crypto/src/` and confirm definitions plus the wire-format barrel re-export
- [ ] 3.3 Run `grep -RnE "DOMAIN_VAULT_V1|DOMAIN_PUBLIC_KEYCHAIN_V1|DOMAIN_PACKER_V1|DOMAIN_CHUNK_V1|DOMAIN_CRL_V1|DOMAIN_AUDIT_V1|DOMAIN_SLSA_BUILDER_V1|DOMAIN_BITPACKER_SIGNED_V1|DOMAIN_BITPACKER_HASHCHAIN_V1|DOMAIN_BITPACKER_MULTISIGN_V1" packages/crypto/crypto/src/domain-tags/` and confirm exactly the 10 v1 tags exist
- [ ] 3.4 Run `grep -RnE "KEYCHAIN.UNKNOWN_LAYOUT|KEYCHAIN.LAYOUT_TOO_WIDE|KEYCHAIN.LAYOUT_ALREADY_REGISTERED|KEYCHAIN.NOT_YET_VALID|KEYCHAIN.EXPIRED|KEYCHAIN.UNKNOWN_PURPOSE|KEYCHAIN.SLOT_INVALID|AUTHORITY.DISPOSED|AUTHORITY.ABORTED|AUTHORITY.REVOKED" packages/crypto/crypto/src/errors/` and confirm every error code cited in the spec is defined
## 4. External-reference audit
- [ ] 4.1 Run `grep -rln "@suaveplan/crypto-keys" packages/ scripts/ docs/ apps/` and confirm zero matches outside `openspec/archive/`
- [ ] 4.2 Run `grep -rln "openspec/specs/crypto-keys" .` and confirm zero matches outside `openspec/changes/crypto-spec-restore-versioning/` and `openspec/archive/`
- [ ] 4.3 If any external reference is found, block the change and update the consumer to reference `openspec/specs/crypto/` (the surface was folded into `@suaveplan/crypto`)
## 5. OpenSpec validation
- [ ] 5.1 Run `bun openspec validate crypto-spec-restore-versioning --strict` and confirm zero errors
- [ ] 5.2 Run `bun openspec list` and confirm `crypto-spec-restore-versioning` appears with `tasks` status reflecting current progress
- [ ] 5.3 Run `bun openspec diff crypto-spec-restore-versioning` and confirm it emits a sensible delta listing 13 added requirements on `crypto` and 5 removed requirements on `crypto-keys`
## 6. Apply the change
- [ ] 6.1 Run `bun openspec apply crypto-spec-restore-versioning` (or follow the equivalent manual fold per `openspec/AGENTS.md`): copy the 13 ADDED requirements from `openspec/changes/crypto-spec-restore-versioning/specs/crypto/spec.md` into `openspec/specs/crypto/spec.md` directly after the existing `### Requirement: deriveKeyFromPassword` block; remove the entire `openspec/specs/crypto-keys/` directory
- [ ] 6.2 Confirm `openspec/specs/crypto-keys/` no longer exists on disk
- [ ] 6.3 Confirm `openspec/specs/crypto/spec.md` now contains the 13 new requirement headings exactly as authored
## 7. Post-apply validation
- [ ] 7.1 Run `bun openspec validate --strict` against the entire spec corpus and confirm zero errors
- [ ] 7.2 Run `bunx turbo lint typecheck test --filter=@suaveplan/crypto` and confirm green (this is a spec-only change but the package gate must remain green)
- [ ] 7.3 Run `bunx turbo test:coverage --filter=@suaveplan/crypto` and confirm 100% across statements / branches / functions / lines (no regression)
- [ ] 7.4 Run `bun scripts/conformance/check-crypto-core-allowlist.ts` and confirm `0 violations`
## 8. Archive
- [ ] 8.1 Move `openspec/changes/crypto-spec-restore-versioning/` to `openspec/archive/<yyyy-mm-dd>-crypto-spec-restore-versioning/` per `openspec/AGENTS.md`
- [ ] 8.2 Confirm the archive contains `proposal.md`, `design.md`, `tasks.md` (with all checkboxes ticked), and `specs/crypto/spec.md` + `specs/crypto-keys/spec.md`
- [ ] 8.3 Update any tracker (`STATUS.md`, in-flight changes list) to reflect the archive
- [ ] 8.4 Commit the change in a single commit titled `crypto-spec-restore-versioning: fold versioned-key vocabulary into live crypto spec`