did-btcr2-js

ADR 034: KeyManager.canExport capability and optional exportKey

Status: Accepted

Date: 2026-05-21

Branch / PR: refactor/kms-signing-flow

References: ADR 007, ADR 012, ADR 033

Context

@did-btcr2/api’s key-management facade (KeyManagerApi) exposes an export(id) method that returns a full SchnorrKeyPair from the backing KeyManager. The bundled LocalKeyManager supports this because it holds raw secret bytes in the JS heap. External KeyManager adapters that we want to encourage (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, hardware wallets, HSMs) typically forbid key export by design: that is the whole point of using such a service.

Before this ADR, the facade reached for instanceof LocalKeyManager to gate export support:

export(id: KeyIdentifier): SchnorrKeyPair {
  if (!(this.kms instanceof LocalKeyManager)) {
    throw new Error('Key export is not supported by the current KeyManager implementation.');
  }
  return this.kms.exportKey(id);
}

That gate has three problems:

  1. It hardcodes a relationship to one concrete class. A third-party in-process adapter (e.g. a FileBackedKeyManager that does support export with the same security model) would be rejected for the wrong reason.
  2. It encodes the capability in a structural check (class identity) rather than in the interface contract. Adapter authors have to read the api code to learn the rule.
  3. KeyManager.exportKey is currently a method on LocalKeyManager only, not on the interface, so calling code that holds a KeyManager reference cannot ask “can I export?” without an instanceof jump.

Decision

Add two members to the KeyManager interface:

KeyManagerApi.export checks both before delegating:

export(id: KeyIdentifier): SchnorrKeyPair {
  if (!this.kms.canExport || !this.kms.exportKey) {
    throw new Error(
      'Key export is not supported by the current KeyManager implementation. '
      + 'The adapter must advertise `canExport: true` and provide an `exportKey` method.'
    );
  }
  return this.kms.exportKey(id);
}

The bundled LocalKeyManager declares readonly canExport = true and continues to expose exportKey as before (no behavior change for in-process callers).

Consequences

Pattern

Optional interface members + capability flags are a lightweight alternative to a parallel Exportable interface or a KeyManager & Exportable mixin. We pick this because (a) the capability is a single boolean and a single method, (b) we already have a stable KeyManager interface and do not want to fragment it across multiple “shapes,” and (c) the pattern composes cleanly if future capabilities are added (e.g., canRotate, rotateKey?(id)).