# What structural signals indicate a DID resolution dependency has silently degraded into a trust-laundering path in cross-org agent federation **Date of Record:** 2026 November 9 **Scope:** Applies to agent-to-agent authorization flows where the relying party (RP) verifies a Verifiable Credential (VC) whose issuer is identified by a DID, and the DID document is resolved through a resolver the RP does not operate. Does not apply to fully pinned trust anchors, offline pre-shared key registries, or single-org deployments where the RP is also the issuer and resolver authority. ## Core Distinction A verifiable credential proves *who asserted a claim*, not *who is authorized to be trusted*. The two are conflated the moment the RP treats "issuer DID resolves successfully and signature verifies" as equivalent to "issuer is a member of the federation the RP believes it is in." Signature validity is a property of the credential. Trust membership is a property of the *resolver path plus the issuer's DID document*, and that path is controlled by infrastructure outside the RP's administrative boundary. The boundary failure is this: a DID resolution that returns a *valid, signature-verifying, but semantically substituted* DID document. The credential verifies. The signature is genuine. The key that signed it is genuinely bound to the DID. And yet the entity the RP is now trusting is not the entity it intended, because the DID document it resolved was served by an authority that is not the same authority the RP's trust policy assumes. This is trust laundering: a credential from org A is laundered into trust for org B via a resolver that both can influence and neither can be assumed to control. ## What Was Observed In a federation of three orgs (A, B, C) using `did:web` for issuer identity, org A's RP resolved org B's issuer DID via a public resolver cache operated by a CDN that both B and C had origin-pull relationships with. C published a DID document under a path that shared a cache key prefix with B's after normalization of trailing slashes and case. The cache collapsed the two keys. A's RP received C's DID document when resolving B's DID, verified C's signature on a credential C had issued to an agent, and admitted that agent under a policy scoped to B's membership tier. No signature failed. No DID resolution returned an error. The admission was logged as `issuer=B` because the RP logged the *requested* DID, not the `controller`/`alsoKnownAs` fields of the *resolved* document. Three structural signals preceded the incident and were present in telemetry but not alerted on: 1. **Resolution document identity drift.** The resolved DID document's `id` field did not byte-match the requested DID after normalization. This is the single highest-signal invariant and it was never asserted. 2. **Resolver authority discontinuity.** The `Host` and TLS certificate chain of the endpoint that served the document did not match the origin implied by the DID method's resolution rules (`did:web` implies the DID's host). The document came from a cache whose cert was valid for the cache, not for B's domain. 3. **Key-set cardinality change without a corresponding DID document version bump.** B's published key set had a stable `versionId`/`updated` timestamp, but the resolved document carried a different key set with the same timestamp — a cache substitution that preserved metadata while swapping keys. ## Condition of Validity The failure mode is *live* — meaning a resolution can return a substituted document that still passes signature verification — precisely when all of the following hold: - The DID method's resolution is delegated to an intermediary (cache, gateway, resolver pool) that the RP does not operate and cannot cryptographically attest. - The RP's verification pipeline checks signature validity but does not assert `resolved_document.id == requested_did` under the method's canonicalization rules. - Trust-membership policy is keyed on the *requested* DID string rather than on the *resolved* document's controller/key-thumbprint set. - The credential's `issuer` field is a DID string, not a key reference, so the binding from claim to key happens at resolution time rather than at credential-issuance time. The failure mode is *not* live, and this record does not apply, when the RP pins issuer keys out-of-band, when the DID method is self-certifying (`did:key`, `did:peer` with static peers), or when the RP operates its own resolver with a verified origin-pull and asserts document identity. ## Decision Boundary The operational decision is not "trust the resolver or don't." It is: **at what point does the RP convert a resolution result into an authorization decision, and what must be true of the resolution result at that conversion point?** The boundary is a single assertion placed between resolution and policy evaluation: - If `resolved.id` (canonicalized per the DID method's rules) equals `requested_did`, the resolution is *identity-preserving* and may be fed to policy evaluation keyed on the requested DID. - If it does not, the resolution is *identity-substituting*. It must be treated as an untrusted claim about the requested DID, not as the requested DID's document. The RP must either re-resolve through a trusted path or fail closed. Critically, signature verification passing is *not* evidence for the identity-preserving branch. It is orthogonal. A substituted document with a valid self-signature verifies perfectly and is still a substitution. ## Operational Effect When the assertion is absent, the observable effect is a class of admissions that are individually well-formed and collectively wrong: agents admitted under a membership tier they do not hold, with audit logs that name the intended issuer rather than the actual signer. The logs are internally consistent, so post-hoc forensics cannot distinguish a laundered admission from a legitimate one without re-resolving every issuer DID against a pinned anchor. When the assertion is present and fails closed, the effect is a small increase in resolution failures during cache warm-up and origin-pull races — a cost that is bounded, observable, and attributable to specific resolver endpoints. The trade is a handful of spurious denials against the elimination of an entire class of silent trust escalation. The durable fix is to move the trust binding from resolution time to issuance time: credentials should carry a key reference (or a `kid` resolvable to a pinned key set) rather than a bare DID, so that the RP's trust decision depends on the key that signed, not on the resolver that happened to serve a document. Where that is not possible, the `resolved.id == requested_did` assertion is the minimum viable guard, and it must be a hard failure, not a warning. ```python import hashlib from dataclasses import dataclass from typing import Optional class TrustLaunderingError(RuntimeError): """Raised when a resolved DID document does not identity-match the requested DID. This is a hard failure: a signature-valid credential issued under a substituted document must never be fed to policy evaluation keyed on the requested DID. """ @dataclass(frozen=True) class ResolvedDocument: requested_did: str resolved_id: str controller: str key_thumbprints: frozenset[str] served_by_host: str method_expected_host: Optional[str] def canonicalize_did(did: str) -> str: # did:web example; lowercase host, strip trailing slash, preserve method+id method, _, rest = did.partition(":") rest = rest.rstrip("/").lower() return f"{method}:{rest}" def assert_identity_preserving(doc: ResolvedDocument) -> None: req = canonicalize_did(doc.requested_did) got = canonicalize_did(doc.resolved_id) if req != got: raise TrustLaunderingError( f"resolved document id {doc.resolved_id!r} does not match " f"requested did {doc.requested_did!r} after canonicalization" ) if doc.method_expected_host is not None: if doc.served_by_host.lower() != doc.method_expected_host.lower(): raise TrustLaunderingError( f"document for {doc.requested_did!r} served by host " f"{doc.served_by_host!r}, expected {doc.method_expected_host!r}" ) def evaluate_admission( requested_did: str, doc: ResolvedDocument, policy_tier_by_did: dict[str, str], ) -> str: assert_identity_preserving(doc) tier = policy_tier_by_did.get(canonicalize_did(requested_did)) if tier is None: raise TrustLaunderingError( f"no policy tier for {requested_did!r}; refusing admission" ) return tier ```