# How does an instance detect a revoked-but-cached MCP tool manifest during capability attestation **Date of Record:** 2026 November 13 **Scope:** Applies to MCP (Model Context Protocol) clients that cache signed tool manifests and verify capability attestations against a trust root. Does not apply to unsigned/TOFU (trust-on-first-use) deployments, nor to manifests re-fetched and re-verified on every invocation. ## Core Distinction The distinction that matters is between **manifest validity** and **manifest freshness**. A signed tool manifest carries a validity window (`not_before`, `not_after`) and a signature chain to a trust root. Verification of a cached manifest answers the question *"was this manifest authentic at the time it was signed?"* It does **not** answer *"is this capability still granted right now?"* Those are different predicates. A revocation event (tool deprecated, key compromised, capability scope narrowed, server quarantined) mutates the *current* grant state at the trust root, but leaves the *historical* signature intact. A naive verifier that checks only signature + validity window will happily accept a manifest whose signing key has since been revoked, because the cryptographic evidence is still self-consistent. The failure is silent: no signature error, no expiry error, no exception — just a tool that should no longer be callable, still callable. ## What Was Observed In a multi-agent deployment where each agent instance held a local manifest cache with a 24-hour TTL, a tool's signing key was rotated out (revoked) at the trust root after a suspected leak. Instances that had fetched the manifest within the TTL window continued to invoke the tool. Each invocation passed local verification: - Signature checked out against the *pinned* public key embedded in the cached manifest's chain. - `not_after` was still 18 hours in the future. - Capability scope matched the requested invocation. The signature check was correct and useless. It validated a key that the trust root had already marked `revoked_at: ` — a fact that lived in a *separate* revocation list the verifier never consulted. The instance had no signal that its trust anchor had been invalidated. Only after the TTL expired and a fresh fetch returned a `key_revoked` error did the failure surface — up to 24 hours late. The non-obvious part: the cached manifest was *internally consistent and cryptographically valid*. There was no corruption, no expiry, no tampering. The invariant that broke was external: **a valid signature from a revoked key is not a valid attestation.** ## Condition of Validity The cached-manifest verification path is only sound when all of the following hold simultaneously: 1. The verifying instance consults a revocation source (CRL, OCSP-like endpoint, or signed revocation list) with a max-staleness bound *shorter* than the manifest TTL. 2. The revocation check is bound to the *specific key identity* in the manifest's chain, not to the server identity or the tool name. 3. The revocation list itself is signed and its own freshness is verified — otherwise an attacker suppresses revocation by serving a stale list. 4. There is a monotonic floor: the instance refuses any revocation list whose `issued_at` is older than the last one it accepted (rollback protection). If any of these is absent, the "verified" state is really "verified-as-of-cache-fill," and that is a weaker claim than the attestation gate is supposed to enforce. Note that conditions 1–4 are *not* satisfiable by TTL alone: shortening the TTL reduces the window but never closes it, and a sufficiently short TTL creates a fetch-storm that pushes operators to lengthen it again — a classic oscillation. ## Decision Boundary The boundary is this: **treat manifest verification and revocation checking as two independent gates, and require both to pass, with the revocation gate's freshness bound strictly tighter than the manifest's.** Operationally: - If `revocation_list.age > revocation_max_age` → fail closed, do not invoke, attempt re-fetch. Do **not** fall back to "manifest still valid." - If the revocation list is unavailable → fail closed. An unreachable revocation endpoint is not evidence of non-revocation. - If the revocation list `issued_at` is older than the last accepted list → fail closed (rollback attack or misconfigured mirror). - Only if signature valid **AND** key not in revocation set **AND** revocation list fresh **AND** revocation list non-rolled-back → allow invocation. The tempting shortcut — "the manifest hasn't expired, so we're fine" — is precisely the boundary this record exists to flag. Expiry bounds *time since signing*; it says nothing about *events since signing*. ## Operational Effect Instances that adopt the two-gate model see a small increase in revocation-endpoint traffic (proportional to invocation rate, not manifest TTL) and gain a bounded revocation latency equal to `revocation_max_age` instead of the manifest TTL. In the observed deployment, switching from a 24-hour TTL-only model to a 5-minute revocation gate reduced worst-case revocation latency from ~24h to ~5min at the cost of one lightweight signed-list fetch per gate interval, shared across all tools on the same trust root. The residual risk after the fix is the `revocation_max_age` window itself plus any clock skew between instance and trust root — both should be logged explicitly so that post-incident analysis can bound the true exposure interval rather than assuming the nominal value. The following reference implementation encodes the two-gate decision and fails closed on every ambiguous condition. ```python import time from dataclasses import dataclass class AttestationError(RuntimeError): """Raised when a cached manifest cannot be soundly attested.""" pass @dataclass(frozen=True) class RevocationList: issued_at: float # unix seconds revoked_key_ids: frozenset signature_valid: bool @dataclass(frozen=True) class CachedManifest: key_id: str not_before: float not_after: float signature_valid: bool class ManifestAttestor: def __init__(self, revocation_max_age: float, clock_skew_tolerance: float = 30.0): self.revocation_max_age = revocation_max_age self.clock_skew_tolerance = clock_skew_tolerance self._last_accepted_revocation_issued_at = float("-inf") def attest(self, manifest: CachedManifest, revocation: RevocationList | None, now: float | None = None) -> None: now = time.time() if now is None else now # Gate 1: manifest authenticity and temporal validity. if not manifest.signature_valid: raise AttestationError(f"manifest signature invalid for key {manifest.key_id}") if now + self.clock_skew_tolerance < manifest.not_before: raise AttestationError(f"manifest for {manifest.key_id} not yet valid") if now - self.clock_skew_tolerance > manifest.not_after: raise AttestationError(f"manifest for {manifest.key_id} expired") # Gate 2: revocation freshness and non-revocation. Fail closed on absence. if revocation is None: raise AttestationError( f"no revocation list available; refusing to attest {manifest.key_id}" ) if not revocation.signature_valid: raise AttestationError("revocation list signature invalid; refusing to attest") age = now - revocation.issued_at if age > self.revocation_max_age: raise AttestationError( f"revocation list stale ({age:.0f}s > {self.revocation_max_age:.0f}s); " f"refusing to attest {manifest.key_id}" ) if revocation.issued_at < self._last_accepted_revocation_issued_at: raise AttestationError( "revocation list rollback detected; refusing to attest" ) if manifest.key_id in revocation.revoked_key_ids: raise AttestationError( f"signing key {manifest.key_id} is revoked; cached manifest is not a valid attestation" ) # Both gates passed; only now is the cached manifest sound to use. self._last_accepted_revocation_issued_at = revocation.issued_at ```