did-btcr2-js

ADR 038: MuSig2 Key Custody - Bounded, Zeroized Secrets at the Participant Boundary

Status: Accepted

Date: 2026-06-20

Branch / PR: feat/aggregation-kms

References: ADR 008, ADR 020, ADR 034, ADR 037

Context

ADR 008 chose MuSig2 (BIP-327) over a Taproot key-path as the aggregate-signing primitive and set the trust model: the coordinator is trusted for liveness only, never signing authority, and every participant self-custodies its key. ADR 020 placed keys in the layer-2 wrapper classes (AggregationParticipant, AggregationService), outside the pluggable transport. ADR 037 shipped the two-axis beacon model and explicitly deferred “the MuSig2 key-custody story (KMS)” to a later ADR. This is that ADR: it pins down exactly how a participant’s raw secret is held and handled across a signing round.

MuSig2 requires the raw 32-byte secret, twice, and a generic signer cannot stand in. A participant’s secret enters the protocol at two call sites in signing-session.ts:

A generic KeyManager.sign(message) / Signer.sign(data, scheme) is a one-shot (digest) -> signature primitive. It cannot express MuSig2: there is no stateful nonce-commitment round, no hook to feed back the aggregated nonce, and a partial signature is not a standalone bip340/bip341 signature that any SigningScheme can return. The secret must remain a raw scalar so the library can multiply it by a_i and add the nonce scalars. The only KeyManager route to the raw secret is exportKey() (the ADR 034 canExport capability), which defeats a non-extractable / HSM-backed manager: a canExport: false manager cannot participate in aggregation at all. Full KMS opacity is therefore mathematically unavailable for MuSig2.

What the current code actually does (verified against the source):

The design question this ADR answers: how does a participant supply its raw secret to the two MuSig2 calls without holding it for its whole lifetime, without leaking unwiped copies, and without the coordinator or transport ever touching it?

  Secret lifetime Zeroization Coordinator holds secret? MuSig2 works?
A: route through KeyManager.sign() (KMS-opaque) n/a n/a no no (partial sig not expressible; only exportKey() yields the secret)
B: bound + zeroized at the participant boundary (chosen) one signing session all transient copies + nonce, all paths no (type-enforced) yes
B2: KMS-native MuSig2 capability (secret never copied out) inside the keypair/KMS inherent (no copy-out) no yes
C: status quo participant lifetime nonce only, success path only no yes

Decision

Adopt Option B: confine the raw secret to a per-signing-session boundary that zeroizes after use, and make the coordinator’s pubkey-only status a type-level invariant. The secret crosses no boundary outward; only public material (compressed pubkey, public nonce, partial signature) leaves the participant.

  1. Bound the secret’s lifetime to a signing session, not the participant. Replace the long-lived keys: SchnorrKeyPair on AggregationParticipant with a narrower secret-provider boundary: the raw secret is yielded to the two MuSig2 calls only for the duration of nonce-generation and partial-signing (a scoped withSecret(fn) / per-session signer), after which the transient copy is wiped. The participant no longer retains a secret-bearing keypair across cohorts.
  2. Add a reusable zeroization utility (wipe(bytes: Uint8Array)), shared from the keypair/common layer, and apply it to every transient raw-secret copy at the nonceGen and Session.sign call sites. This complements the existing Secp256k1SecretKey.destroy() rather than re-inventing it.
  3. Give BeaconSigningSession deterministic secret-nonce teardown on all paths. Make secretNonce private, add an explicit clear() / dispose() that zeroizes it, and invoke it on abort, failure, and completion, not only on the success path of generatePartialSignature. A second partial-sign attempt continues to throw rather than reuse a nonce (MuSig2 nonce reuse is catastrophic key leakage).
  4. Narrow the coordinator to a public-key type. Type AggregationService (and AggregationServiceRunner) key material as public-key-only, since the service never signs. This turns “the coordinator never holds signing authority” (ADR 008) from a runtime fact into a compile-time invariant.
  5. Keep secrets out of the transport registry. AggregationRunner.solo() registers only public keys with the transport actor registry; the secret stays at the participant boundary and is not aliased across the runner/transport/session.

Rejected alternatives

Consequences

Positive

Negative

Accepted

References