# How does an instance detect a rotated DID controller key that still verifies under a cached federation trust anchor **Date of Record:** 2026 November 14 **Scope:** Applies to any agent instance that validates inbound cross-org Verifiable Credentials (VCs) against a locally cached federation trust anchor set keyed by Decentralized Identifier (DID) and DID document version. Does not apply to single-org intra-tenant attestation, nor to first-contact resolution where the DID document is fetched fresh and no anchor cache exists. ## Core Distinction The failure is not "an expired credential" nor "a revoked DID." It is the silent divergence between **anchor identity** and **anchor key material**. A federation trust anchor is typically pinned as `(did, controller, publicKeyFragment, validFrom)`. When the controller rotates the signing key — legitimately, via a `did:web` document update or a `did:key` re-derivation — the DID string is unchanged, the controller field is unchanged, and the key fragment name is often reused (`#key-1`). Every field the cache keys on still matches. Only the *key bytes* differ. A verifier that resolves `did → cached anchor → key fragment name → key bytes from the *cached* document*` will accept credentials signed by the old key indefinitely, and reject credentials signed by the new key. Both behaviors are wrong, and neither raises an error at the trust-anchor layer. The failure surfaces as an authentication failure far downstream — usually as "signature invalid" on a credential that is, in fact, perfectly valid. ## What Was Observed During a cross-org federation between two agent meshes, org B rotated the controller key on its root DID `did:web:agents.b.example`. The rotation was announced out-of-band and the new DID document was published with `validFrom` set to the rotation instant. Org A's verifier had a 24-hour anchor refresh TTL and continued to serve the pre-rotation document from cache. Observed sequence: 1. Org B issued VCs signed with the new key. Org A rejected them with `SignatureVerificationError` — not `TrustAnchorStaleError`. The error was attributed to org B, and a retry storm began. 2. Simultaneously, org A *accepted* VCs signed with the compromised old key for the remainder of the TTL, because the cached document still listed it as authorized. This is the security-relevant half of the bug: rotation is precisely the moment the old key must stop being trusted, and the cache guaranteed the opposite. 3. No log line indicated a version mismatch. The DID document `versionId` field was present in the fetched document but was not stored in the anchor cache, so the verifier had no basis to compare. The invariant that was violated: **a credential is valid only if signed by a key that was authorized at the credential's `issuanceDate`, evaluated against the DID document version in force at that time — not against the version in force at verification time, and not against a cached version.** The cache conflated "the anchor's current key" with "the anchor's key at issuance." ## Condition of Validity The detection procedure below is valid only when all of the following hold: - The DID document exposes a monotonic `versionId` (or equivalent) and the credential carries a trustworthy `issuanceDate` (signed by the issuer, not the transport). - The federation anchor cache stores the full resolved document, not just the key fragment bytes. - The verifier can fetch the *current* document on demand for the purpose of comparison, even while refusing to use it as the verification key. - Clock skew between orgs is bounded and known; `issuanceDate` comparisons must use a tolerance window, not strict ordering. It does **not** hold for `did:key`, which has no mutable document and cannot rotate in place — a key change there is a new DID and is out of scope. It also does not hold when the issuer signs with a key not listed in any document version (self-asserted keys), where there is nothing to compare. ## Decision Boundary The decision boundary is between **cache-as-optimization** and **cache-as-authority**. A cached anchor may be used to *short-circuit the fetch*, but it must never be used to *decide authorization* without a version check. Concretely: - If `cached.versionId == current.versionId`: verify with cached key bytes. Fast path. - If `cached.versionId != current.versionId`: do **not** verify against either key blindly. Re-evaluate the credential against the document version whose validity interval contains `issuanceDate`. If that version is not retrievable (pruned history), reject with a *distinguishable* error — `AnchorVersionUnavailable` — not a generic signature failure. - If the current document no longer lists the key used to sign, and the credential's `issuanceDate` postdates the rotation, reject as `KeyNotAuthorizedAtIssuance`. - If the current document no longer lists the key but `issuanceDate` predates the rotation, accept *and* emit a `PostRotationVerification` audit event — this is the case that was silently accepted before and must now be visible. The boundary is crossed the moment a verifier treats "the DID resolves to the same controller" as "the trust anchor is unchanged." Identity continuity is not key continuity. ## Operational Effect After implementing version-aware anchor validation, the retry storm collapsed: org B's new-key credentials verified on first attempt because the verifier fetched the current document, saw the version bump, and selected the correct key by `issuanceDate`. The old-key window closed at the rotation instant rather than at TTL expiry. The remaining cost is one document fetch per version transition per anchor, which is negligible against the credential volume. The residual hazard is history pruning: if a federation prunes old document versions faster than the maximum credential lifetime, `AnchorVersionUnavailable` becomes reachable in normal operation. That is a federation policy constraint, not a verifier bug, and must be surfaced as such rather than masked as a signature failure. ```python from dataclasses import dataclass from datetime import datetime, timedelta CLOCK_SKEW_TOLERANCE = timedelta(minutes=5) class TrustAnchorDivergenceError(RuntimeError): """Raised when a cached anchor and the resolved document disagree on key material.""" pass class AnchorVersionUnavailable(TrustAnchorDivergenceError): """The document version in force at issuanceDate has been pruned.""" pass @dataclass(frozen=True) class DIDDocumentVersion: version_id: str valid_from: datetime valid_until: datetime | None key_fragments: frozenset[str] @dataclass(frozen=True) class CachedAnchor: did: str controller: str version_id: str key_fragments: frozenset[str] def select_key_version( cached: CachedAnchor, history: list[DIDDocumentVersion], issuance_date: datetime, ) -> DIDDocumentVersion: """Pick the document version authorized at issuanceDate, never the cached one blindly.""" if cached.did != cached.did.lower(): raise TrustAnchorDivergenceError(f"anchor DID not normalized: {cached.did}") target = issuance_date - CLOCK_SKEW_TOLERANCE for version in history: if version.valid_from <= target and ( version.valid_until is None or target <= version.valid_until ): if version.version_id != cached.version_id: # Cache diverged from history: this is the silent-rotation case. raise TrustAnchorDivergenceError( f"anchor {cached.did} cached at {cached.version_id} " f"but issuance falls in {version.version_id}" ) return version raise AnchorVersionUnavailable( f"no document version for {cached.did} covers issuance {issuance_date.isoformat()}" ) def verify_credential(cached: CachedAnchor, history: list[DIDDocumentVersion], issuance_date: datetime, signing_fragment: str) -> bool: version = select_key_version(cached, history, issuance_date) if signing_fragment not in version.key_fragments: raise TrustAnchorDivergenceError( f"fragment {signing_fragment} not authorized in {version.version_id}" ) return True ```