did-btcr2-js

ADR 088: Enforce capabilityInvocation Membership When Resolution Applies an Update

Status: Accepted

Date: 2026-08-19

Branch / PR: fix/critical-high-security-findings

References: ADR 016, ADR 051, ADR 055, ADR 057, ADR 085

Context

Resolution of a did:btcr2 identifier replays every announced update against the document as it stood at the previous version (ADR 016). The step that decides whether an update counts is Resolver.applyUpdate (packages/method/src/core/resolver.ts:596): it dereferences the proof’s root capability and checks that the capability’s invocationTarget and controller are this DID, reads proof.verificationMethod, locates that method with DidBtcr2.getSigningMethod, verifies the Data Integrity proof against the method’s published key, applies the JSON Patch, and checks the resulting document against update.targetHash.

Nothing in that sequence tied the named method to an authorization. getSigningMethod searches didDocument.verificationMethod, which is the document’s key directory, not a grant of authority. Every key a controller publishes appears there, including keys published to be used for something other than updating the DID. The verification relationships (authentication, assertionMethod, capabilityInvocation, capabilityDelegation) are the only mechanism a DID document has for saying “this key may authenticate but must not rewrite me”, and the read path ignored them entirely.

The result was a full, persistent takeover of a DID by a key deliberately excluded from update authority. A controller publishes #device under authentication so a device can log in; whoever holds #device signs an update whose patch adds #device to capabilityInvocation, announces it through the beacon, and resolution accepts it. From that version on the attacker is an authorized controller and the original key cannot revoke the grant, since revocation is itself an update the attacker can outpace. Driven against the real state machine, that sequence resolved successfully to version 3 with the attacker’s key listed in capabilityInvocation.

The specification’s resolve algorithm already required the missing half. Its “Check update.proof” step says to locate the method in current_document.verificationMethod and to raise INVALID_DID_UPDATE if current_document.capabilityInvocation does not contain update.proof.verificationMethod. Only the first conjunct was implemented.

The write path never had this hole: DidBtcr2.update (packages/method/src/did-btcr2.ts:175) has always refused a verificationMethodId that is absent from the source document’s capabilityInvocation, and ADR 051 additionally binds the signer’s key to that method. But the write path is a convenience for honest callers. An attacker constructs the update by hand, or calls the static Updater.sign directly, and neither costs anything. Only the read path decides what the network sees, so an authorization rule that lives solely on the write path is not an authorization rule at all. This is the same read-path/write-path asymmetry that ADR 055 addressed for caller-supplied resolution data.

Decision

1. Resolution refuses an update whose proof method is not authorized

applyUpdate asserts that the contemporary document’s capabilityInvocation names proof.verificationMethod before doing anything with the proof (packages/method/src/core/resolver.ts:639-651). A failure raises the typed ResolveError with type INVALID_DID_UPDATE (ADR 085), carrying { verificationMethodId, capabilityInvocation } so a caller can see both the method that was claimed and the list it was measured against.

Two properties of the placement matter as much as the check:

2. Both sides of the comparison are normalized to an absolute method id

The module-private relationshipMethodId(documentId, entry) (packages/method/src/core/resolver.ts:171-176) reduces a verification relationship entry to the absolute DID URL of the method it names: an embedded verification method object becomes its id, a string reference is the id itself, and a bare fragment such as #key-0 is resolved against documentId. Both the proof’s verificationMethod and every capabilityInvocation entry go through it before comparison.

This is required for correctness, not convenience. DID Core permits a relationship entry to be either a string reference or an embedded verification method, and permits relative DID URLs, so a literal string comparison would refuse spec-valid documents. The case is not hypothetical for EXTERNAL (x1) identifiers: a genesis document written with relative references keeps that form through the placeholder substitution done at resolution, while its verificationMethod[].id values and the update proof both carry absolute URLs. Exact-only matching would render such a DID permanently unresolvable, which is a worse failure than the one being fixed.

The normalization is deliberately narrow, and its safety rests on two properties:

Unwrapping is one level deep and non-recursive by construction, so no crafted nesting can drive it.

Nine regression cases in packages/method/tests/resolver.spec.ts cover both directions: the five refusals (the authentication-only takeover, a method under no relationship, a document whose capabilityInvocation a prior update removed, the foreign-DID fragment, a non-string verificationMethod) and four guards proving the check is not over-broad (the identity key, a key granted invocation by the previous update, a bare-fragment reference, and an embedded method object).

Consequences

Rejected Alternatives