Files
suaveplan/openspec/archive/2026-05-03-restore-crypto-opinionated-layer/specs/crypto/spec.md
2026-05-27 23:29:53 +01:00

163 lines
9.4 KiB
Markdown

# Capability Spec Delta: crypto (restore-crypto-opinionated-layer)
**Package:** `@suaveplan/crypto`
**Status:** PROPOSED
> Each ADDED requirement carries a `**Verified by:**` line. The full umbrella spec at `restore-crypto-keychain-packer/specs/crypto/spec.md` enumerates per-scenario detail; this slice's spec covers the seven new modules.
## ADDED Requirements
### Requirement: `InMemoryAuthority` concrete backend
The package SHALL ship `InMemoryAuthority`, a concrete implementation of the `Authority` interface that signs under a `DerivationCoordinate` slot, rejects revoked slots, enforces the slot's validity window against the injected `Clock`, and zeroises every secret on `dispose()`.
#### Scenario: signing with an in-window slot succeeds
- **WHEN** a caller asks `authority.sign(domainTag, payload)` against a slot whose `notBefore <= clock() < notAfter`
- **THEN** a verifiable signature is returned
#### Scenario: signing with a revoked slot fails
- **WHEN** the same call is issued against a slot whose `revokedAt` is in the past
- **THEN** `CryptoError(AUTHORITY.SLOT_REVOKED)` is thrown
#### Scenario: dispose zeroises secrets
- **WHEN** `authority.dispose()` is called
- **THEN** every slot's `secretKey` byte buffer is overwritten with zeros and subsequent sign calls throw `AUTHORITY.DISPOSED`
**Verified by:** `src/authority/in-memory-authority/in-memory-authority.test.ts::"sign-with-in-window-slot succeeds"` AND `::"sign-with-revoked-slot fails"` AND `::"dispose-zeroises-secrets"`
### Requirement: Hierarchical identity operations
The package SHALL ship `RootIdentity`, `ServerIdentity`, `createRoot`, `createServer`, `rollServer`, and `revokeServer`. `createServer` produces a server identity signed under the supplied root authority; `rollServer` increments the version, emits a CRL entry for the prior version, and appends an audit-log entry; `revokeServer` appends CRL + audit entries without re-issuing.
#### Scenario: createRoot self-verifies
- **WHEN** `createRoot(rng, clock, options)` runs
- **THEN** the produced root's public bundle verifies under its own sign slot
#### Scenario: rollServer increments version and emits CRL
- **WHEN** `rollServer(rootAuthority, serverIdentity, options)` runs
- **THEN** the new server's version is `prior.version + 1`, a CRL entry exists for `prior.version`, and an audit-log entry exists for the rotation
**Verified by:** `src/identity/operations/operations.test.ts::"createRoot self-verifies"` AND `::"createServer is signed by root"` AND `::"rollServer increments version + emits CRL"` AND `::"revokeServer appends without re-issue"`
### Requirement: Three-regime `Vault` replaces the legacy implementation
The package SHALL ship a three-regime `Vault`: (a) public bundle (parent-signed plaintext), (b) private-key blob (password-AEAD, rotatable via `changePassword` without re-derivation), (c) vault payload (auth-box-sealed under keypair, parent-signed under `DOMAIN_VAULT_V1`, carries `master`/`seedCount`/`keychain`/`data: Uint8Array`). The transitional `legacy-vault/` from slice 2 MUST be deleted in this slice.
#### Scenario: round-trip preserves payload data
- **WHEN** a `Vault` packs a payload containing `data: Uint8Array([1,2,3])` and is then unpacked
- **THEN** `data` round-trips byte-identically
#### Scenario: changePassword does not re-derive the keychain
- **WHEN** a `Vault` is unlocked, `changePassword(newPassword)` is called, and the vault is re-locked then re-unlocked with `newPassword`
- **THEN** the recovered keychain's slots are byte-identical to the original (no re-derivation occurred)
#### Scenario: lock zeroises secrets
- **WHEN** `vault.lock()` is called
- **THEN** every secret byte buffer reachable from the vault is overwritten with zeros
**Verified by:** `src/vault/vault.test.ts::"round-trip preserves payload data"` AND `::"changePassword without re-derive"` AND `::"lock zeroises secrets"` AND `::"BIP39 restore re-derives identical keychain"` AND `::"Argon2id parameter floor enforced"`
### Requirement: `Packer` / `Unpacker` envelope with replay protection
The package SHALL ship `Packer.pack(value, schema, recipientAuthority, senderAuthority, domainTag)` producing an encrypt-then-sign envelope including `issuedAt` and `nonce`. `Unpacker.unpack(envelope, recipientAuthority, senderPublicBundle, domainTag, options)` MUST reject envelopes whose `issuedAt` is older than `options.replayWindowMs` or whose `nonce` was already seen.
#### Scenario: round-trip preserves the value
- **WHEN** Packer.pack then Unpacker.unpack with matching parties and domain tag
- **THEN** the unpacked value is structurally equal to the original
#### Scenario: replay outside window rejected
- **WHEN** an envelope produced 1 hour ago is unpacked with `replayWindowMs = 60_000`
- **THEN** `CryptoError(PACKER.REPLAY_REJECTED)` is thrown
#### Scenario: cross-protocol domain tag rejected
- **WHEN** an envelope packed under `DOMAIN_PACKER_V1` is unpacked with `DOMAIN_VAULT_V1`
- **THEN** `CryptoError(PACKER.DOMAIN_TAG_MISMATCH)` is thrown
**Verified by:** `src/packer/packer.test.ts::"round-trip preserves the value"` AND `::"replay outside window rejected"` AND `::"cross-protocol domain tag rejected"`
### Requirement: `ChunkedPacker` per-chunk-signed fragmentation
The package SHALL ship `ChunkedPacker.packFileToChunks` and `ChunkedUnpacker.reassembleFile`. Each chunk MUST carry an independent signature; tampering with any single chunk's bytes MUST cause reassembly to fail.
#### Scenario: round-trip reassembles byte-identical
- **WHEN** a 10 KiB stream is packed into 4 chunks then reassembled
- **THEN** the reassembled stream is byte-identical to the input
#### Scenario: tampered chunk rejected at reassembly
- **WHEN** any byte of any single chunk is flipped before reassembly
- **THEN** `CryptoError(CHUNK.TAMPERED)` is thrown with the offending chunk index
**Verified by:** `src/chunked-packer/chunked-packer.test.ts::"round-trip reassembles byte-identical"` AND `::"tampered chunk rejected at reassembly"`
### Requirement: `RevocationList` root-signed CRL
The package SHALL ship a `RevocationList` that is append-only, root-signed, and `verify`-able against a root public bundle. Mutating any past entry MUST cause the list-level signature to fail verification.
#### Scenario: append then verify succeeds
- **WHEN** a CRL is appended to and then verified against the issuing root's public bundle
- **THEN** verification succeeds
#### Scenario: mutating a past entry invalidates the list
- **WHEN** any byte of an existing CRL entry is altered post-append
- **THEN** `CryptoError(CRL.MUTATED)` is thrown on next verify
**Verified by:** `src/revocation/revocation-list.test.ts::"append then verify succeeds"` AND `::"mutating a past entry invalidates the list"`
### Requirement: `AuditLog` append-only Merkle log
The package SHALL ship an `AuditLog` whose root is signed by a root authority. Tampering with any past entry MUST invalidate the root signature; an inclusion proof MUST be derivable for any appended entry.
#### Scenario: append then root signature verifies
- **WHEN** events are appended and the log root is signed
- **THEN** `auditLog.verify(rootPublicBundle)` returns true
#### Scenario: tampering with a past entry invalidates the root
- **WHEN** any byte of an appended event is flipped
- **THEN** `auditLog.verify(rootPublicBundle)` throws `CryptoError(AUDIT.ROOT_MISMATCH)`
**Verified by:** `src/audit-log/audit-log.test.ts::"append then root signature verifies"` AND `::"tampering with a past entry invalidates the root"` AND `::"inclusion proof verifies for appended entries"`
### Requirement: Eight wire-format schemas with golden-bytes KAT vectors
The package SHALL define and module-export eight wire-format schemas (`DERIVATION_COORDINATE_SCHEMA`, `KEY_SLOT_PRIVATE_SCHEMA`, `KEY_SLOT_PUBLIC_SCHEMA`, `VAULT_PAYLOAD_SCHEMA`, `PUBLIC_BUNDLE_SCHEMA`, `CHAIN_BUNDLE_SCHEMA`, `CRL_ENTRY_SCHEMA`, `AUDIT_ENTRY_SCHEMA`) and ship a golden-bytes KAT fixture per schema under `test-fixtures/wire-format/`. CI MUST assert byte-for-byte equality between the schema's encoding of canonical inputs and the committed fixtures.
#### Scenario: every schema is module-exported
- **WHEN** the test imports every named schema from `@suaveplan/crypto/wire-format`
- **THEN** every import resolves
#### Scenario: KAT bytes match committed fixtures
- **WHEN** each schema encodes its canonical input
- **THEN** the bytes are equal to the matching `test-fixtures/wire-format/*.bin`
**Verified by:** `tests/wire-format-kat.test.ts::"every wire-format schema is module-exported"` AND `tests/wire-format-kat.test.ts::"KAT bytes match committed fixtures for each schema"`
### Requirement: Property-based round-trip coverage
Every pack/unpack pair in the package SHALL be covered by a `fast-check` property test asserting `unpack(pack(v)) ≡ v` over at least 100 generated inputs per property.
#### Scenario: round-trip is identity for every pack/unpack pair
- **WHEN** a fast-check property runs over 100 generated inputs for any pack/unpack pair
- **THEN** every iteration round-trips structurally
**Verified by:** `tests/property-roundtrips.test.ts::"round-trip identity for keychain"` AND `::"round-trip identity for vault"` AND `::"round-trip identity for public-bundle"` AND `::"round-trip identity for chain-bundle"` AND `::"round-trip identity for packer-envelope"` AND `::"round-trip identity for chunk"` AND `::"round-trip identity for crl-entry"` AND `::"round-trip identity for audit-entry"`