Status: Accepted
Date: 2026-06-26
Branch / PR: fix/update-verifies-signing-key
References: ADR 002, ADR 012, ADR 016, ADR 025
The sign step of a did:btcr2 update builds a multikey from the caller’s signer and produces a Data Integrity proof over the unsigned update (ADR 002). The signer is supplied by the caller: a local secret key, a key-manager-backed signature, or any custom backend (ADR 012). The proof records the verification method it claims to satisfy, taken from the document’s capabilityInvocation.
Nothing checked that the signer’s public key is the key that verification method publishes. A caller who supplies the wrong signing key (a different keystore entry, a key mis-derived after rotation, the wrong active key) produces a structurally valid update whose proof is signed by a key the named method does not list. The proof is internally well-formed but cryptographically unverifiable against the document.
That failure was silent at signing time. It surfaced only much later, when a resolver replaying the update verifies the proof against the document’s published key and rejects it (ADR 016) - after the update had been funded and broadcast. The cost of the mistake was an irreversible on-chain announcement that anchors an update no one can verify, with no signal at the point where the mistake was made.
The sign step is a single chokepoint. Both the state-machine path (the updater emits a signing-key need; the caller provides a signer) and the direct static path (scripts and the SDK calling Updater.sign outside the state machine) route through the same function (ADR 025). The SDK and the CLI both drive updates through it. A check placed there covers every write path; a check placed anywhere else has to be duplicated per consumer or is missed.
Updater.sign compares the multibase-encoded public key carried by the signer’s multikey against the named verification method’s publicKeyMultibase. On mismatch it raises a typed update error and produces no proof. Both encodings are the same canonical form the document uses to record the key, so the comparison is a direct string equality of values already computed, not new cryptography.
Because the state-machine path and the direct static path both call Updater.sign, the guard protects the builder, the SDK, and the CLI from one place. None of those layers re-derives or re-compares keys; the error propagates through their existing error handling.
The check runs at signing, the earliest point the mismatch is detectable and the phase before the update is funded or broadcast. A wrong signing key now costs nothing on-chain: the caller gets a clear, typed error that names the verification method, instead of an unverifiable update that is only discovered after an announcement is spent.
The guard runs whenever the verification method carries a publicKeyMultibase, which a well-formed btcr2 Multikey method always does. When the field is absent, the guard does not invent a key to compare against and does not block signing; it is a consistency check between two stated keys, not a second validator of method shape.
The sign-time guard does not replace the resolver’s proof verification. A resolver still rejects any update whose proof signature does not match the document’s published key, so an update that bypasses this guard (constructed by other means, or signed against a method an attacker controls while claiming the document’s method id) is still caught at resolution (ADR 016). The sign-time guard is the fast local fence that saves a wasted broadcast; the resolver is the trustless backstop that does not depend on the writer having run the fence.
publicKeyMultibase and the signer’s multikey exposes the same canonical multibase, so comparing the two encodings directly matches how the document states the key and needs no decode step.