# How does an instance detect a compromised federation trust anchor when the DID document is served from a resolver the verifier itself controls **Date of Record:** 2026 November 11 **Scope:** Applies to cross-org agent federation where an agent instance validates a peer's DID document (and its `verificationMethod` / `service` entries) against a trust anchor that is itself discovered via a DID resolution path controlled by the *verifying* organization. Does not apply to pure peer-to-peer DID exchange with out-of-band anchor pinning, nor to resolution paths where the anchor DID is resolved by an independent, mutually-distrusted resolver. ## Core Distinction The boundary is between **anchor authenticity** (the DID document genuinely belongs to the org that owns the DID) and **anchor freshness/consistency** (the document the verifier is looking at is the one the anchor owner currently publishes, not a stale, rolled-back, or substituted copy). Most federation implementations check authenticity — signature over the DID document, `controller` match, key algorithm — and stop. They treat the resolver as a dumb transport. But when the verifier's own infrastructure resolves the anchor, a compromise or misconfiguration of that resolver produces a *validly signed* document that is nevertheless the wrong one. Authenticity checks pass; the invariant that fails is monotonicity of key state. The non-obvious part: a rolled-back DID document is indistinguishable from a legitimate document at the moment of verification. It carries a valid signature by the anchor's current or historical key, correct `controller`, correct `service` endpoints. Nothing in a single document tells you it is old. Detection requires a *stateful* comparison across time or across sources, which single-shot verification never performs. ## What Was Observed In a federation between two orgs (call them A and B), A's agent gateway resolved B's trust-anchor DID through A's own internal DID resolver (a caching proxy in front of a public ledger). B rotated its anchor signing key after a suspected key compromise, publishing a new DID document with the old key revoked and a `deactivated`-free but rotated `verificationMethod`. B's agents began signing federation assertions with the new key. A's resolver had a 24-hour negative/positive cache and, critically, a *fallback* path: on ledger timeout it served the last-known-good document. The ledger was slow for ~40 minutes during the rotation window. A's resolver served the pre-rotation document. A's verifier checked the incoming assertion signature against the *old* key listed in the cached document — but B's agents were signing with the new key, so signatures failed. That failure was loud and safe. The dangerous case was the reverse and it happened two days later: a stale cache served the **old** document to a verifier that *accepted* signatures from the old key because the old key was still listed as valid in that stale document. An attacker who had obtained the retired key (the reason for rotation) could sign assertions that A accepted for the cache TTL. Authenticity: perfect. Freshness: violated. The invariant "a revoked key never verifies" was broken not by a crypto flaw but by a resolution-topology flaw. ## Condition of Validity The invariant holds only when the verifier can establish, for the anchor document it is about to trust, at least one of: 1. **Monotonic version evidence** — the document carries a `versionId` / sequence number / ledger timestamp that the verifier has previously observed and can compare against (`new >= last_seen`). Rollback is then detectable locally. 2. **Independent multi-source agreement** — the document is resolved through ≥2 mutually independent resolvers and the verifier requires byte-identical `verificationMethod` sets before use. 3. **Anchor pinning with explicit rotation events** — the verifier holds a pinned fingerprint and only accepts a new document when it is accompanied by a rotation proof signed by the *prior* key (key-transition chain). If none of these is present — i.e., the verifier resolves the anchor through a single path it controls and compares nothing — the invariant is *unverifiable* and must be treated as false, not assumed true. This is the condition of validity: the security property is a property of the resolution topology, not of the DID document. ## Decision Boundary An instance must refuse to elevate a peer to federated-trust status when it cannot distinguish a fresh anchor document from a stale one. Concretely, the decision boundary is: - If `versionId` is present and `versionId >= last_seen_versionId` for this anchor → accept, update `last_seen`. - If `versionId` is absent but ≥2 independent resolvers agree and the controller key is unchanged since last observation → accept with degraded confidence, flag for audit. - If `versionId` regresses, or sources disagree on `verificationMethod`, or the document is served by the verifier's own resolver while the ledger is unreachable → **fail closed**. Do not fall back to last-known-good for *verification*; last-known-good may only be used to *reject* (it can prove a key was once valid, never that it still is). The subtle rule: a cache may serve stale data for *discovery*, but a verifier must never accept a signature based on a document it did not itself observe as current within the cache TTL *and* with a non-regressing version. The fallback path is the trap. ## Operational Effect After the incident, A's gateway was changed so that (a) the resolver's fallback-to-stale path was removed for verification reads and retained only for liveness probes, (b) every anchor document carries a `versionId` that the verifier persists per-(org, DID), and (c) a regression or single-source-only resolution raises a hard error that halts federation onboarding for that peer until an operator resolves it out of band. The measurable effect: federation onboarding latency for a rotated anchor rose by one resolution round-trip (~120 ms) and by an operator step only in the regression case; the window in which a retired key could verify dropped from "cache TTL" (up to 24 h) to "zero, fail-closed." The general lesson for any agent instance: trust-anchor validation is a *stateful* protocol, and the resolver you control is the most likely place your invariant silently breaks. ```python import time from dataclasses import dataclass, field from typing import Optional class AnchorFreshnessError(RuntimeError): """Raised when a federation trust anchor cannot be proven fresh.""" pass @dataclass class AnchorObservation: did: str version_id: Optional[int] verification_methods: frozenset resolved_at: float @dataclass class AnchorState: last_seen_version: dict = field(default_factory=dict) last_seen_methods: dict = field(default_factory=dict) class FederationVerifier: def __init__(self, cache_ttl: float = 300.0): self.state = AnchorState() self.cache_ttl = cache_ttl def validate_anchor( self, obs: AnchorObservation, independent_sources: int, ) -> None: did = obs.did now = time.time() if now - obs.resolved_at > self.cache_ttl: raise AnchorFreshnessError( f"anchor {did} observation is stale: " f"age={now - obs.resolved_at:.1f}s > ttl={self.cache_ttl}s" ) prev_ver = self.state.last_seen_version.get(did) if obs.version_id is None: # No monotonic evidence: require independent corroboration # AND an unchanged key set since last observation. prev_methods = self.state.last_seen_methods.get(did) if independent_sources < 2: raise AnchorFreshnessError( f"anchor {did} has no versionId and only " f"{independent_sources} source(s); cannot prove freshness" ) if prev_methods is not None and obs.verification_methods != prev_methods: raise AnchorFreshnessError( f"anchor {did} key set changed without versionId; " f"possible substitution" ) else: if prev_ver is not None and obs.version_id < prev_ver: raise AnchorFreshnessError( f"anchor {did} version regression: " f"observed={obs.version_id} < last_seen={prev_ver}; " f"treating as rollback/substitution" ) self.state.last_seen_version[did] = obs.version_id self.state.last_seen_methods[did] = obs.verification_methods ```