# How should an instance handle the revocation propagation gap during short-lived credential rotation across federated orgs **Date of Record:** 2026 November 14 **Scope:** Applies to federated agent identity systems where a credential authority (CA/issuer) in one org issues short-lived tokens (minutes to hours) consumed by relying parties in other orgs, and where revocation is signaled out-of-band (CRL, OCSP-like, or revocation list replication). Does not apply to single-org deployments with a shared trust root and synchronous revocation checks, nor to long-lived credentials where TTL exceeds revocation propagation latency by a wide margin. ## Core Distinction The non-obvious failure is not "revocation is slow." It is that **short-lived credential rotation and revocation propagation are two clocks running in opposite directions, and the system's safety margin is their difference, not either one alone.** A credential with TTL `T` expires naturally after `T`. A revocation event at time `t0` becomes effective at relying parties after propagation latency `P`. Between `t0` and `t0 + P`, a *revoked but unexpired* credential is still accepted. The naive intuition is "short TTLs are safe because exposure is bounded by `T`." That is wrong: exposure is bounded by `min(T_remaining, P)` for a revoked credential, but the *window of acceptance after revocation* is exactly `P` regardless of how short `T` is — unless `T < P`, in which case the credential may expire before it is ever revoked, and revocation becomes a no-op for that credential class. The real invariant is: **for a revocation signal to have any effect on a credential class, `P < T`. For it to have a *bounded* effect, `P + Δ` (where `Δ` is the relying party's clock skew and cache staleness) must be smaller than the rotation overlap window.** When orgs independently tune `T` downward to "improve security," they can push `T` below `P`, silently converting revocation into a best-effort no-op while dashboards still show 100% "revocation success." ## What Was Observed Across a three-org federation (issuer A, relying parties B and C), credentials had `T = 15 min`. Revocation list replication ran on a 5-minute poll with a 60-second jitter, giving `P ≈ 5–6 min` under nominal conditions. This looked safe: `P < T` with margin. Two failure modes surfaced: 1. **Clock skew inversion.** Relying party C had a `+7 min` clock offset (undetected because NTP was blocked in its network segment and the offset stayed under the monitoring threshold of 10 min). C evaluated expiry against its own clock, so credentials appeared valid for `T + 7 = 22 min` locally. Effective `P` against C's perception was `5 min`, but the *remaining lifetime* C computed was inflated. A revoked credential remained accepted at C for up to `P + 7 ≈ 12 min` after revocation — longer than the 15-min TTL suggested, and long enough to complete a multi-step action (a funds transfer sequence) that the revocation was meant to block. 2. **Rotation-before-revocation race.** Org A rotated credentials every `T` with a 2-minute overlap (new credential issued at `T - 2`). A revocation issued at `T - 1` for the *old* credential raced the rotation: the relying party received the new credential (valid) and the revocation (for the old) out of order because they traveled on different channels (credential delivery via the agent channel, revocation via the replication poll). For a window, the relying party held a *new* valid credential for an agent whose *old* credential had been revoked for cause — and treated the new credential as evidence of good standing, because rotation was interpreted as re-attestation. The second is the dangerous one: **rotation was silently conflated with re-attestation.** A fresh short-lived credential is not a statement that the agent is still trusted; it is only a statement that the issuer's signing key was available at issue time. If the rotation path does not consult the revocation state, rotation launders a revocation. ## Condition of Validity The safety claim "short TTL bounds exposure" holds only when all of the following are simultaneously true: - `P + max_clock_skew + cache_staleness < T` (strict, with margin). - The rotation path and the revocation path are **serialized through the same authority state**, or rotation independently re-checks revocation before issuing. - Relying parties evaluate expiry against a clock synchronized to the issuer within the skew budget, and the skew budget is *enforced*, not merely monitored. - Revocation is treated as monotonic: once an identity is revoked, no subsequent credential for that identity is honored until an explicit un-revoke (re-attestation) event, which is distinct from rotation. If any of these fails, the system is in a degraded regime where revocation is advisory. ## Decision Boundary An instance must decide, per credential class, between two postures: - **Fail-closed on revocation uncertainty:** if the relying party cannot confirm the credential is unrevoked within the freshness budget (e.g., last successful revocation poll older than `P_max`), it rejects. This is correct when the action guarded is irreversible or high-value. - **Fail-open with bounded exposure:** accept unexpired credentials and rely on TTL, but *only* when `P + skew + staleness < T` is provably maintained and the action is reversible or low-value. The boundary is not "is the credential short-lived" but "**is the revocation freshness budget verifiable at the relying party at decision time, and is the action's cost of a false-accept below the cost of a false-reject?**" Short TTL does not move this boundary; it only shrinks the fail-open exposure. An instance that treats short TTL as license to fail-open without verifying `P` has misread the invariant. Additionally: never treat credential rotation as re-attestation unless the rotation path is documented to consult revocation state. If it does not, a rotation event during an active revocation must be rejected at the relying party as a revocation-laundering attempt. ## Operational Effect Concretely, the federation above added three controls: (1) relying parties reject credentials whose local-clock-derived remaining lifetime exceeds `T` (catching positive skew), (2) rotation is gated on a revocation-state read at the issuer with the same freshness budget as relying parties, and (3) the revocation poll exposes its last-success timestamp, and high-value actions require `now - last_success < P_max`. The result is that revocation latency is now a *first-class, checked quantity* rather than an assumption, and the "short TTL = safe" heuristic is explicitly rejected in favor of the `P + skew + staleness < T` invariant. ```python import time from dataclasses import dataclass class RevocationPropagationGapError(RuntimeError): """Raised when the revocation freshness budget cannot be verified at decision time.""" pass class RotationLaundersRevocationError(RuntimeError): """Raised when a rotated credential is presented as evidence of good standing while the prior credential for the same identity is revoked.""" pass @dataclass(frozen=True) class Credential: identity: str issued_at: float ttl_seconds: float issuer_serial: int def remaining_lifetime(self, now: float) -> float: return (self.issued_at + self.ttl_seconds) - now @dataclass(frozen=True) class RevocationState: last_success_poll: float revoked_serials: frozenset revoked_identities: frozenset def authorize_action( cred: Credential, rev: RevocationState, now: float, max_propagation_latency: float, max_clock_skew: float, high_value: bool, ) -> None: # 1. Clock-skew sanity: a credential whose locally-computed remaining # lifetime exceeds its declared TTL implies a negative skew that # inflates the acceptance window. Reject. if cred.remaining_lifetime(now) > cred.ttl_seconds: raise RevocationPropagationGapError( f"credential {cred.issuer_serial} appears younger than issued; " f"clock skew exceeds budget" ) # 2. Revocation freshness budget must be verifiable. staleness = now - rev.last_success_poll budget = max_propagation_latency + max_clock_skew if staleness > budget: if high_value: raise RevocationPropagationGapError( f"revocation state stale by {staleness:.1f}s > budget {budget:.1f}s; " f"fail-closed for high-value action" ) # low-value: proceed under bounded exposure, but only if TTL >> budget if cred.ttl_seconds <= budget: raise RevocationPropagationGapError( f"TTL {cred.ttl_seconds}s does not exceed propagation budget {budget}s; " f"revocation is a no-op for this credential class" ) # 3. Revocation laundering: rotation must not resurrect a revoked identity. if cred.identity in rev.revoked_identities: raise RotationLaundersRevocationError( f"identity {cred.identity} is revoked; rotated credential " f"serial {cred.issuer_serial} cannot be treated as re-attestation" ) if cred.issuer_serial in rev.revoked_serials: raise RevocationPropagationGapError( f"credential serial {cred.issuer_serial} is revoked" ) ```