Status: Accepted
Date: 2026-06-27
Branch / PR: fix/resolver-provide-hardening
The Resolver is a sans-I/O state machine (ADR 016): it emits typed DataNeed requests and the caller fulfills them through provide(). That makes provide() a trust boundary. The data crossing it, Bitcoin beacon signals, CAS announcements, signed updates, SMT proofs, genesis documents, comes from the network and from sidecar bundles, so it is influenceable by whoever produced the on-chain signals or assembled the sidecar.
Three gaps sat on that boundary:
Unvalidated payloads. provide() validated the SMT proof’s root hash against the need’s smtRootHash, but it did not validate the CAS announcement or the signed update against the need’s announcementHash / updateHash. The on-chain signal commits to a specific hash; the caller could nonetheless supply a different announcement or update, and it was accepted and stored. Resolution then either failed opaquely much later (the lookup by the committed hash missed) or proceeded on data the signal never committed to.
Unchecked casts. provide() cast each payload with as and no runtime shape check, so a malformed payload flowed downstream as a bad cast and surfaced as a confusing error far from the boundary where it entered.
Unbounded discovery. After applying the updates found so far, the resolver looks for beacon services those updates added and loops back to discover their signals. That loop had no bound. A crafted document whose updates keep adding beacon services drives discovery without terminating: an unbounded-work vector.
provide() now checks the CAS announcement’s canonical hash against need.announcementHash and the signed update’s canonical hash against need.updateHash, mirroring the SMT root-hash check that was already there. A mismatch throws ResolveError (INVALID_DID_UPDATE) at the boundary. Correct callers are unaffected: the hash already matches the on-chain commitment, which is exactly why the downstream lookups worked.
provide() runtime-checks each payload’s shape, a Map for beacon signals, the required fields for a CAS announcement, a signed update, and an SMT proof, and an object for a genesis document, and throws a typed error naming the need when the shape is wrong, instead of forcing it through with an as cast.
A maxDiscoveryRounds option (default 10, exposed on ResolutionOptions) caps the apply-then-rediscover loop. Exceeding it throws ResolveError (INVALID_DID_DOCUMENT). A well-formed document reaches a fixed point in a handful of rounds; the cap stops a pathological or malicious chain of beacon services from looping without end.
provide(), not silently used. Resolution fails with a clear, local error instead of an opaque downstream one or a proof over unverified data.ResolutionOptions gains an additive maxDiscoveryRounds (default 10). Existing callers are unchanged and protected by default; a caller with an unusual but legitimate topology can raise it, and a cautious caller can lower it.provide() boundary is precisely where network-influenced data enters the sans-I/O core; validating there fails fast and locally. Downstream-only validation surfaces errors far from their cause, and for the discovery loop it never fires at all.