Status: Accepted
Date: 2025-10-28
Commit: 0893492
Before this commit, key management logic lived inside packages/method/src/core/key-manager/, mixing three distinct concerns into one folder:
@did-btcr2/keypair (and, for Multikey-wrapped keys, to @did-btcr2/cryptosuite).keypair nor cryptosuite had an opinion here.SingletonBeacon acquiring a keypair by service.id to sign a Bitcoin PSBT, for example. That’s protocol code.The mixing produced several concrete pains:
method couldn’t be consumed without a key store. Any code path that touched signing also needed the full key-manager apparatus, even for a pure-resolution consumer who never signed anything.There was also a forward-looking concern: the team already knew that an HD-wallet app (Rolohex) would consume the did:btcr2 library, and that app would want to manage a thousand keys through a structured lifecycle: generate, import, tag, derive, rotate. Either the lifecycle primitives were going to live in a dedicated package with a clean interface, or every wallet consumer would grow its own adapter layer around the internals of method.
method. It’s DID-specific anyway and every operation goes through method. Minimum disruption. Blocks HSM/hardware/cloud pluggability; keeps the key-lifecycle surface invisible from outside method; forces every consumer into the same in-memory store shape.keypair. Same package owns everything key-related. But keypair is a primitive package: it owns “what is a secp256k1 key pair.” Loading it with store abstractions and active-key bookkeeping contaminates a package whose strength is that it’s small and crypto-only.@did-btcr2/kms package with its own KeyManager interface and pluggable KeyValueStore. Lifecycle concerns live in kms. Primitives stay in keypair. method consumes kms by interface, so consumers can swap in HSM/hardware/cloud without touching method at all.Option 3. On 2025-10-28 (commit 0893492), @did-btcr2/kms@0.1.0 is initialized. The package boundary is defined by:
KeyManager interface: the small set of operations that every key store must support: generateKey, importKey, removeKey, listKeys, getPublicKey, sign, verify, digest, plus an active-key concept (activeKeyId, setActiveKey). Pluggable implementations (HSM, hardware wallet, cloud KMS, browser-local) conform to this interface; downstream code (starting with SingletonBeacon acquiring keys by service.id) holds a KeyManager reference, not a concrete class.KeyValueStore<K, V> storage abstraction: a small interface (get, set, delete, has, clear, entries) that the default Kms implementation uses for backing storage. MemoryStore is the default concrete implementation; IndexedDB, file-backed, or encrypted stores plug in by implementing the interface.KeyManager method is synchronous. This keeps the MVP tight and matches the noble/scure crypto primitives that are themselves synchronous. Asynchronous stores (cloud KMS, remote HSM) adapt by wrapping or by a separate AsyncKeyManager contract if that becomes necessary: deferred.Kms supports import/generate/sign/verify, a single KeyValueStore, and a Signer class as a compatibility wrapper for PSBT signing (later removed by ADR 012). Watch-only entries, tags, scheme options, URN identifiers, and dual Schnorr/ECDSA signing through one interface all come later: but they come as refinements on top of this package, not as changes to method or keypair.service.id. SingletonBeacon acquires a keypair from KMS using the beacon service’s ID as the lookup. The method package holds no key state of its own.The package-graph consequence is clean: kms depends on keypair (for the SchnorrKeyPair return shape) and common (for shared types). method depends on kms. Consumers who never sign: pure resolvers: never import kms.
Positive
keypair stays a small crypto-primitive package. kms owns lifecycle and storage. method uses keys without owning them.YubiHsmKeyManager implements KeyManager and drops it in; method and api don’t know or care.Kms with a fresh MemoryStore; no cross-test state.kms, its store abstraction, or anything that transitively depends on those.Negative
KeyManager interface starts minimal. Watch-only entries, per-key tags, scheme-selectable signing, and URN-style identifiers had to be added later (ADR 012). The minimum-viable-interface choice at inception meant later additive breaking changes to that interface. An alternative was to design the full interface upfront, but doing so without real use cases would have produced speculative shape.KeyManager directly. Async adapters have to wrap or mimic. An AsyncKeyManager contract is a deferred future problem; the sync shape is correct for the 90% case today.Explicitly accepted trade-offs
exportKey, removeKey, sign are ungated at the package level. Policy enforcement (who can sign what, audit logs, rate limiting) is the responsibility of a consumer wrapping KeyManager: the package provides the primitive, not the governance.rotateKey() method would embed a specific rotation lineage model that doesn’t map to every consumer’s rotation policy.packages/kms/src/interface.ts: KeyManager interface and supporting types.packages/kms/src/kms.ts: default Kms implementation.packages/kms/src/store.ts: KeyValueStore<K,V> interface, MemoryStore.packages/method/src/core/beacon/singleton-beacon.ts: SingletonBeacon acquires keys through the KeyManager interface.kms is a layer-one package between keypair and method.KeyEntry. Builds on the boundary decision made here.