Initial Commit
This commit is contained in:
98
openspec/archive/2026-05-17-add-crypto-keys/proposal.md
Normal file
98
openspec/archive/2026-05-17-add-crypto-keys/proposal.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Add @suaveplan/crypto-keys — hierarchical keychain chain-signing
|
||||
|
||||
**Package**: `@suaveplan/crypto-keys`
|
||||
**Status**: PROPOSED
|
||||
**Type**: genesis-original (inspired-by-upstream)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/crypto-keys/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Upstream `@suaveplan/keys` at `../suaveplan/packages/universal/keys/` shipped a hierarchical key-signing primitive (`ChainedKeySigner`) that genesis has no equivalent for. The pattern is well-defined: a chain of public keychains, each one signed by its parent's sign-key, with the root self-signed and verification walking back up the chain to a trusted root. Use cases include: root → server → user key issuance with end-to-end provenance; attestation of derived keychains in multi-tenant deployments; recoverable root-of-trust trees in vault systems. None of these are achievable today on genesis with the existing `@suaveplan/crypto`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-vault`, or `@suaveplan/vault` packages — they cover primitives, vault storage, and password-based recovery, but not the chain-of-signatures pattern itself.
|
||||
|
||||
Direct port is not viable. Upstream `ChainedKeySigner` is tightly coupled to obsolete machinery: it uses `BitPacker(publicKeychainSchema)` for the wire format and a high-level `sign(signKey, packed)` API that don't exist on genesis. Genesis's `@suaveplan/crypto` re-implemented the keychain layer with a v1 derivation grammar (`encodePublicKeychain`, per-slot `AlgorithmId`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `slotAlgorithmMatchesDefault`, `ALGORITHMS` registry) and `@suaveplan/crypto-curve` exposes only per-algorithm `sign(digest, privateKey)` primitives. The chain-signing pattern needs to be **re-designed** on top of these primitives, not transliterated.
|
||||
|
||||
This proposal defines that redesign as a fresh genesis-original package built around two pluggable interfaces (`ChainHasher` for what-gets-signed and `ChainCodec` for the wire format), three shipped implementations of each (raw / domain-tagged / merkle hashers; TLV / recursive-Ed25519 / CBOR codecs), the `ChainedKeySigner` class that composes them, and a hierarchical-key-factory layer (`createRootKeysWithChain` / `createServerKeysWithChain` / `rollServerKeysWithChain`) on top.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `ChainHasher` interface — `hash(encodedKeys, parent?): Uint8Array`. Produces the bytes a signer signs for one chain level. Three shipped implementations:
|
||||
- `RawHasher` — returns `encodedKeys` unchanged. Default.
|
||||
- `DomainHasher({ domainTag: string })` — returns `sha256(domainTag || encodedKeys)`. Domain tag is mandatory at construction; empty/oversize strings raise `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
|
||||
- `MerkleHasher` — for non-root levels returns `sha256(encodedKeys || sha256(parent.encodedKeys || parent.signature))`; for root returns `sha256(encodedKeys)`. Each level commits to its parent's content.
|
||||
- ADDED: `ChainCodec` interface — `encode(levels): Uint8Array`, `decode(bytes): ChainLevel[]`. Three shipped implementations:
|
||||
- `TlvChainCodec` (default) — `[version=1: u8][hasher: u8]` header, then per level `[algorithm: u8][keys_len: u16 BE][keys][sig_len: u16 BE][sig]`. Self-describing; per-algorithm signatures supported.
|
||||
- `RecursiveEd25519Codec` — `[version=1: u8][hasher: u8][keys][sig: 64][parent?]` recursive. Restricted to Ed25519; any other algorithm raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Keys-section length is the fixed `encodePublicKeychain` output size (read from the genesis crypto constant), so the recursion boundary is unambiguous without a length prefix.
|
||||
- `CborChainCodec` — `[version=1: u8][cbor]` where the CBOR payload is `{ v: 1, hasher: <name>, levels: [{ a: <algorithm>, k: <keys>, s: <signature> }, ...] }`. Depends on `@suaveplan/cbor`.
|
||||
- ADDED: `ChainedKeySigner` class — constructor accepts `{ hasher?, codec? }` with defaults `RawHasher` and `TlvChainCodec`. Two signing surfaces (slot-based ergonomic helper + raw-key fully-general) plus shared verify / parse / strip:
|
||||
- `async signWithSlot(payload: Uint8Array, signSlot: KeySlotPrivate, parent?: Uint8Array): Promise<Uint8Array>` — convenience for sign-slots from genesis `@suaveplan/crypto` keychain. In v0.0.0 only Ed25519 sign-slots are supported (the only signing-capable algorithm in the v1 keychain grammar); other slot algorithms raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Extracts `algorithm` and `publicKey` from the slot.
|
||||
- `async signRaw(options: { payload: Uint8Array; algorithm: SignAlgorithmId; privateKey: Uint8Array; publicKey: Uint8Array; parent?: Uint8Array }): Promise<Uint8Array>` — fully general; supports all 5 signing algorithms exposed by `@suaveplan/crypto-curve` (`ed25519`, `secp256k1`, `p256`, `p384`, `p521`). Caller supplies the algorithm tag, private+public key material, and an arbitrary payload. Dispatches to the matching crypto-curve primitive.
|
||||
- `async verify(chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }): Promise<VerificationResult>` — decodes the chain via the configured codec, walks every level recomputing bytes-to-sign via the hasher, dispatches verification to crypto-curve by each level's stored algorithm, and checks the root level's `(algorithm, signerPublicKey)` matches `trustedRoot` via constant-time compare. Returns `{ valid, depth, levels, errors }`.
|
||||
- `async parse(chainBytes: Uint8Array): Promise<{ depth: number; levels: readonly ChainLevel[] }>` — decode-only (no verification).
|
||||
- `async stripToDepth(chainBytes: Uint8Array, depth: number): Promise<Uint8Array>` — re-encodes the leaf-most `depth` levels only. `depth < 1` or `depth > chain.length` raises `CHAIN_KEYS.INVALID_DEPTH`.
|
||||
- ADDED: `SignAlgorithmId` type — `"ed25519" | "secp256k1" | "p256" | "p384" | "p521"`. Distinct from `@suaveplan/crypto`'s `AlgorithmId` numeric enum (which covers all crypto algorithms, not just signing); `SignAlgorithmId` is the string-tagged subset this package signs/verifies. The TLV codec maps `SignAlgorithmId` ↔ `u8` enum bytes.
|
||||
- ADDED: `ChainLevel` type — `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. The `payload` is whatever the signer was asked to sign (typically `encodePublicKeychain(...)` output for keychain chains, but the package treats it as opaque bytes). The `signerPublicKey` is recorded per-level so verifiers can run end-to-end without parsing each level's payload. `VerificationResult` — `{ valid, depth, levels, errors }`.
|
||||
- ADDED: Hierarchical factory functions — Ed25519-only convenience wrappers over `generateKeychain` + `signWithSlot`:
|
||||
- `createRootKeysWithChain({ signer?, ...generateOptions }): Promise<{ keys: Keychain; chain: Uint8Array }>`
|
||||
- `createServerKeysWithChain({ rootKeys, rootChain, signer?, ...generateOptions }): Promise<{ keys; chain }>`
|
||||
- `rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer? }): Promise<{ keys; chain }>` — bumps the keychain version using `generateKeychain({ version: currentVersion + 1, masterKey: rootKeys.master.private })`.
|
||||
- ADDED: `CHAIN_KEYS_CODES` registry via `defineCodes("CHAIN_KEYS", [...])` — 7 codes: `UNSUPPORTED_ALGORITHM`, `INVALID_FORMAT`, `INVALID_SIGNATURE`, `TRUST_MISMATCH`, `INVALID_DEPTH`, `INVALID_KEYCHAIN`, `INVALID_DOMAIN_TAG`.
|
||||
- ADDED: `ChainKeysError extends SuaveplanError<ChainKeysErrorCode>` with optional `depth?: number` and `algorithm?: AlgorithmId` diagnostic discriminants.
|
||||
- ADDED: Subpath exports for every feature module — `./error-codes/error-codes`, `./types/chain-level`, `./types/hasher`, `./types/codec`, `./hashers/raw/raw`, `./hashers/domain/domain`, `./hashers/merkle/merkle`, `./codecs/tlv/tlv`, `./codecs/recursive-ed25519/recursive-ed25519`, `./codecs/cbor/cbor`, `./signer/signer`, `./factory/factory`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **VaultManager / VaultFactory / VaultStreams / `packVault` / `unpackVault` / `openVault`.** These are vault-orchestration concerns (password+mnemonic recovery, vault rollover, ChunkedPacker integration). They belong in an extension of the existing `@suaveplan/crypto-vault` package, not here. A follow-up `extend-crypto-vault-vault-manager` proposal will own them. Reason: the chain-signing primitive and the vault-as-key-manager are independent layers; keeping them in separate packages prevents `crypto-keys` consumers from pulling in `@suaveplan/drive` and `@suaveplan/streams` they don't need.
|
||||
- **The upstream bitpacker keychain Pack schemas** (`fullKeychainSchema`, `publicKeychainSchema`, `baseKeySchema*`) and the helpers built on them (`packPublicKeys`, `viewPublicKeys`, `prepareKeysForPacking`). Genesis `@suaveplan/crypto` already provides `encodePublicKeychain` / `decodePublicKeychain` / `extractPublicKeys` over the v1 derivation grammar. Re-shipping the upstream bitpacker layer would create two incompatible wire formats for the same data. Reason: genesis crypto's v1 grammar supersedes the upstream pack schemas with stronger algorithm enforcement and validity windows.
|
||||
- **OpenTelemetry instrumentation.** No spans, counters, or meters on this initial version. The package is a pure data transform (encode/decode/sign/verify); the call sites that wrap it will own their observability. Reason: emitting OTel from a leaf primitive doubles the surface for marginal value; revisit once a real consumer asks.
|
||||
- **Schema-registry integration.** `@suaveplan/schema-registry` versioning of the wire format is overkill for a single chain-format version that's already self-describing via its version byte. Reason: the version byte covers forward-compatibility; registry overhead isn't justified for an internal binary contract.
|
||||
- **Streaming verification.** The chain wire format is a small fixed-shape blob (bounded by `8 * KEY_SLOT_PUBLIC_BYTE_SIZE * max_depth` + signatures + framing); streaming adds complexity for no realistic payload size. Reason: chains are always small enough to verify in memory.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Upstream reference: `../suaveplan/packages/universal/keys/src/lib/chain-signer.ts` (419 lines) plus the hierarchical factory chunks of `keys-common.ts` (`createRootKeysWithChain` / `createServerKeysWithChain` / `rollServerKeysWithChain`). The upstream supplies the **contract sketch** (sign/verify/parse/stripToDepth shape, root → server → user pattern, recursive wire format) but every implementation detail is re-derived from genesis primitives. The package is therefore **genesis-original**, not a 1:1 migration.
|
||||
|
||||
### Parity / divergence checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| Hierarchical sign-verify-parse-strip surface | ✅ `signKeysWithChain` / `verifyChain` / `extractKeys`/ `getChainDepth` / `stripToDepth` | ⬆️ Modernized | Methods renamed to `sign`/`verify`/`parse`/`stripToDepth` (idiomatic async API); `extractKeys` and `getChainDepth` fold into `parse(...)`'s return |
|
||||
| Bitpacker-based keychain wire format | ✅ via `BitPacker(publicKeychainSchema)` | 🚫 Replaced | Uses `encodePublicKeychain` from genesis `@suaveplan/crypto` — the v1 derivation grammar |
|
||||
| Fixed secp256k1 signing with recovery | ✅ via `sign(signKey, packed, true)` | ⬆️ Enhanced | Dual surface: slot-based (`signWithSlot`, Ed25519-only in v0.0.0 matching genesis keychain v1) plus raw-key (`signRaw`, all 5 curves via `@suaveplan/crypto-curve` primitives) |
|
||||
| `[keys][sig][parent_chain?]` recursive wire format | ✅ | ⬆️ Generalized | TLV codec ships as default; recursive-Ed25519 codec preserves the upstream shape under its constraint; CBOR codec adds an extensibility option |
|
||||
| `compareKeys` byte-wise equality | ✅ | ⬆️ Constant-time | Verifier uses constant-time compare from `@suaveplan/crypto-utils` |
|
||||
| Raw `throw new Error("...")` | ✅ | ⬆️ Replaced | All raised errors extend `SuaveplanError` with stable `CHAIN_KEYS.*` codes |
|
||||
| `SignedKeyChain` interface | ✅ recursive | ⬆️ Generalized | `ChainLevel[]` array shape; codecs free to encode flat or recursive |
|
||||
| `ChainVerificationResult` | ✅ | ✅ | Preserved as `VerificationResult` |
|
||||
| Domain-tagged signing | 🚫 upstream | ⬆️ Added | `DomainHasher({ domainTag })` ships as an opt-in |
|
||||
| Merkle-style parent commitment | 🚫 upstream | ⬆️ Added | `MerkleHasher` ships as an opt-in |
|
||||
| CBOR codec | 🚫 upstream | ⬆️ Added | `CborChainCodec` ships for extensibility |
|
||||
| Hierarchical factory layer (`createRoot/Server/RollServerKeysWithChain`) | ✅ | ✅ | Preserved but adapted to genesis `generateKeychain` + injected `ChainedKeySigner` |
|
||||
| Vault-side helpers (`packVault`/`unpackVault`/`VaultManager`/`VaultStreams`) | ✅ in upstream `keys` | 🚫 deferred | Belong in a follow-up `crypto-vault` extension; out of scope here |
|
||||
|
||||
### Reference docs consulted
|
||||
|
||||
- ✅ `../suaveplan/packages/universal/keys/src/lib/chain-signer.ts` — full read
|
||||
- ✅ `../suaveplan/packages/universal/keys/src/lib/keys-common.ts` — full read (extracted factory section)
|
||||
- ✅ `../suaveplan/packages/universal/keys/src/keys.types.ts` — full read (informs the divergence rationale)
|
||||
- ✅ `../suaveplan/enterprise/keys.md` — read; notes that the upstream package's purpose is broader than what's scoped here, with clear sectioning of what stays out
|
||||
- ✅ `/home/paul/web/genesis/packages/crypto/crypto/src/keychain/keychain.ts` — full read (the genesis Keychain / PublicKeychain / encode/decode surface)
|
||||
- ✅ `/home/paul/web/genesis/packages/crypto/crypto/src/keychain/index.ts` — full read (public exports of `@suaveplan/crypto` keychain layer)
|
||||
- ✅ `/home/paul/web/genesis/packages/crypto/crypto-curve/src/primitives/*/impl/` — sampled all 5 algorithm primitives (Ed25519 / secp256k1 / p256 / p384 / p521) to confirm per-algorithm `sign(digest, privateKey)` / `verify(...)` signatures
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents today. `@suaveplan/crypto-keys` is being added net-new. The deferred `crypto-vault` extension will be the first consumer, importing `ChainedKeySigner` to wire vault-signing semantics.
|
||||
|
||||
### Risk
|
||||
|
||||
- **Low to medium.** Pure data transform with well-defined primitives. The novel pieces are the wire format (three codecs, each with self-describing version bytes) and the hierarchical factory layer; both are deterministic, in-memory, and exercised by property-based tests on round-trips.
|
||||
- **No breaking changes** to any existing package. Genesis `@suaveplan/crypto` / `crypto-curve` / `crypto-hash` / `cbor` are consumed read-only.
|
||||
- **Test surface is non-trivial:** 3 hashers × 3 codecs × 5 sign algorithms = 45 combinations the round-trip tests cover. Property-based fast-check generators reduce the manual surface considerably.
|
||||
|
||||
### Migration notes
|
||||
|
||||
None. Net-new package on genesis.
|
||||
Reference in New Issue
Block a user