Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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"`
|
||||
Reference in New Issue
Block a user