did-btcr2-js

ADR 012: KMS Dual Signing Schemes, URN-Style Key Identifiers, and Watch-Only KeyEntry

Status: Accepted

Date: 2026-03-13

Commit: 656de39

Context

The KMS (@did-btcr2/kms) sits at the junction of two different signature needs in did:btcr2:

Prior to v0.4.0, the KeyManager interface exposed a single sign() method that was Schnorr-only. To produce ECDSA signatures for Bitcoin PSBT inputs, the KMS shipped a separate Signer class that held a keypair plus a NetworkName and produced ECDSA output through a second path. The SingletonBeacon imported both Kms (for update-proof signing) and Signer (for PSBT signing), which bled NetworkName: a concern that belongs in the Bitcoin/transaction layer: into the KMS package.

Three other issues compounded the problem:

  1. Singleton state. Kms kept a static #instance with a static initialize() entry point. Tests leaked state between specs because nothing ever reset the singleton; order-dependence hid bugs.
  2. Raw KeyBytes storage. The store value type was KeyBytes (32-byte secret key). There was no representation for a watch-only entry: public-key-only: which future HD-wallet callers (e.g. the planned Rolohex app, see Rolohex context memory) need in order to import an xpub subtree and derive child public keys without signing capability.
  3. Pubkey hex as KeyIdentifier. The default key identifier was the compressed pubkey as a hex string. That has three failure modes: it leaks key material into logs and error messages (e.g. "Key not found: 02a1b2..."), it couples key identity to key material (so rotation changes identity), and it collides visually with DID-document key-IDs elsewhere in the codebase.

Each of these individually was tolerable. Together, the KMS interface could not honestly serve a production wallet.

Options considered

  1. Keep Signer for ECDSA, keep the singleton, keep raw KeyBytes. Zero migration cost, but every downstream consumer still has to import both Kms and Signer for any non-Schnorr signing, and the test-isolation and watch-only gaps remain.
  2. Add a scheme option to sign() / verify() but leave the singleton and storage format alone. Removes the need for Signer but leaves test-isolation and HD-wallet concerns unaddressed.
  3. Overhaul: add scheme option, kill the singleton, replace KeyBytes storage with a structured KeyEntry, issue URN-style identifiers, and make exportKey concrete-only.

Decision

Option 3. The v0.4.0 changes, taken together:

Consequences

Positive

Negative

Explicitly accepted trade-offs

References