# Why does an instance of a revoked agent identity continue to authenticate across a cross-org A2A mesh after key rotation propagation? **Date of Record:** 2026 November 7 **Scope:** Applies to federated agent-to-agent (A2A) meshes where identity assertions (signed tokens, SPIFFE-style SVIDs, or OIDC-derived agent credentials) are minted by a home organization and accepted by relying parties in other organizations under a trust bundle. Does not apply to single-org meshes with a shared control plane, nor to short-lived tokens under a single issuer with no cross-org trust delegation. ## Core Distinction The failure is not "revocation did not propagate." Revocation *did* propagate — the home org's revocation list (CRL/deny-list) and the rotated signing key were both published and both reached every relying party within the configured propagation window. The boundary is subtler: **revocation of a credential and invalidation of an already-issued assertion are different operations with different propagation semantics.** A relying party that validates an assertion against a trust bundle checks *signature validity against a key*, not *current membership of the subject in the home org's active set*. When the home org rotates its signing key, assertions signed under the old key remain cryptographically valid until the old key is removed from the trust bundle — and the removal of the old key is governed by a *grace period* designed for continuity, not for revocation. A revoked agent whose last assertion was signed under the old key therefore remains authenticatable for the entire grace window, even though its identity is revoked *now*. The invariant that operators assume — "revoked ⇒ rejected at next validation" — holds only when validation is bound to a live introspection call to the issuer. It does not hold under offline/bundle-based validation, which is the default in cross-org meshes because it avoids per-request round trips to the home org. ## What Was Observed In a three-org mesh (Org A home, Orgs B and C relying parties), an agent `planner-7` under Org A was compromised and revoked at T0. Org A published an updated revocation list and rotated its assertion-signing key at T0+2m. Orgs B and C ingested both within 90 seconds. Despite this: - Requests bearing assertions signed by `planner-7` under the *pre-rotation* key continued to be accepted by Org B until T0+47m and by Org C until T0+58m. - The revocation list was consulted only for *long-lived delegated credentials* (agent enrollment certs), not for per-call assertions. Per-call assertions were validated purely by signature chain. - The old signing key remained in Orgs B and C's trust bundles for a 1-hour "rotation grace" window to avoid breaking in-flight sessions. During that window, any assertion signed by the old key — including one minted by the revoked agent before revocation — validated successfully. - Org C additionally cached the *public key set* at the TLS/HTTP layer for 15 minutes beyond the bundle update, extending its exposure. The net effect: revocation latency was not the configured 90 seconds but the *maximum of (grace window, key-set cache TTL, assertion remaining lifetime)*. The revocation list was a red herring; the binding constraint was trust-bundle key retirement policy. ## Condition of Validity The "revoked ⇒ rejected" invariant is valid **iff** at least one of the following holds at every relying party: 1. Validation performs issuer introspection (OCSP-like or token introspection) per assertion, so revocation state is live; or 2. The revoked subject's *specific* key material (not the org's signing key) is added to a deny-list that relying parties check before signature validation; or 3. The grace window for old signing keys is set to zero and all in-flight assertions are re-minted under the new key at rotation time. In meshes that rely on bundle-based offline validation (the common case for latency and blast-radius reasons), none of these hold by default. The invariant is then **false for the duration of the key grace window**, and no amount of revocation-list propagation shortens it. ## Decision Boundary Operators must choose between two regimes and must not conflate them: - **Continuity regime:** broad key grace windows, bundle-based validation, high availability, revocation effective only at key-rotation boundary. Acceptable when the blast radius of a revoked-but-authenticating agent is bounded (read-only scopes, rate limits, per-org egress controls). - **Revocation-strict regime:** zero-grace key retirement plus per-subject deny-list checks *or* live introspection, at the cost of per-call latency and dependency on home-org availability. The boundary is crossed when a mesh is configured for continuity but is *assumed* to provide revocation-strict semantics — e.g., when a compromised agent holds write or delegation scopes. The decision must be made per scope class, not per mesh: a single mesh can run continuity for read scopes and revocation-strict for write/delegation scopes, with the strict path forcing introspection or deny-list checks. ## Operational Effect Treating revocation and key retirement as independent knobs produces a silent window in which a revoked identity authenticates. The operational consequences are: incident response timelines that assume 90-second revocation but deliver ~1-hour containment; audit logs that show "valid signature" for revoked agents, misleading forensic reconstruction; and cross-org trust negotiations that must explicitly state the grace window as a security parameter, not a mere operational convenience. The fix is to make the grace window a *declared, audited* property of the trust relationship, and to route any scope that can cause harm through a validation path that checks subject-level deny state rather than relying on signing-key retirement. ```python from dataclasses import dataclass from typing import FrozenSet class RevocationWindowViolation(RuntimeError): """Raised when a relying party would accept an assertion from a revoked subject because the old signing key is still within its grace window.""" @dataclass(frozen=True) class TrustBundle: org: str active_key_id: str retired_key_ids: FrozenSet[str] # still accepted during grace window grace_seconds_remaining: int @dataclass(frozen=True) class Assertion: subject: str signing_key_id: str scope_class: str # "read" | "write" | "delegate" def validate_assertion( assertion: Assertion, bundle: TrustBundle, revoked_subjects: FrozenSet[str], strict_scopes: FrozenSet[str] = frozenset({"write", "delegate"}), ) -> None: """Bundle-based validation. Returns None on accept, raises on reject. The subtle boundary: a revoked subject whose assertion is signed by a retired-but-still-graced key passes signature validation. We must refuse that combination for strict scopes, because revocation did not actually take effect for this path. """ if assertion.signing_key_id == bundle.active_key_id: key_ok = True elif assertion.signing_key_id in bundle.retired_key_ids: key_ok = bundle.grace_seconds_remaining > 0 else: key_ok = False if not key_ok: raise RevocationWindowViolation( f"assertion key {assertion.signing_key_id!r} not in trust bundle for {bundle.org}" ) if assertion.subject in revoked_subjects: # Revocation is live, but the key path is not. For strict scopes the # grace window must not be allowed to mask revocation. if assertion.scope_class in strict_scopes: raise RevocationWindowViolation( f"subject {assertion.subject!r} revoked but assertion signed by " f"graced key {assertion.signing_key_id!r} " f"(grace_remaining={bundle.grace_seconds_remaining}s); " f"strict scope {assertion.scope_class!r} requires live deny-list" ) # Read scopes tolerate the window by policy; log and accept. return None ```