# What happens when an instance presents a delegated capability token whose delegation chain contains a revoked intermediate but a still-valid leaf signature **Date of Record:** 2026 November 14 **Scope:** Applies to AAIF deployments using chained delegated capability tokens (attenuated bearer tokens where each hop re-signs the delegation edge) combined with a revocation mechanism that publishes per-credential revocation records. Does not apply to single-hop tokens, to tokens using cryptographic accumulators with non-membership proofs verified in-band, or to deployments where revocation is enforced only at the resource server rather than at every hop. ## Core Distinction The distinction that matters is between **signature validity** and **delegation-chain validity**. A leaf capability token can carry a perfectly valid signature over a well-formed attenuation predicate—correct issuer key, correct audience, correct scope subset, correct expiry—while the chain that authorizes the issuer to *have* that scope is broken by a revocation one or more hops upstream. Most AAIF verifiers conflate these: they verify the leaf's signature and the immediate delegator's binding, then check revocation only against the leaf's own identifier. The intermediate credential that was revoked never appears in the leaf's revocation check because the leaf is identified by its own hash, not by its ancestors'. This is non-obvious because the AAIF spec's revocation section describes revocation records as keyed by credential identifier, and the natural reading is "check the credential you were handed." The failure is that delegation is transitive but revocation checking is, by default, local. ## What Was Observed A three-hop chain was exercised: root agent `R` (holding scope `s3`) delegated to `A` (attenuated to `s2`), which delegated to `B` (attenuated to `s1`), which presented a leaf token to resource server `S`. At T0, credential `A` was revoked (its key was rotated after a suspected compromise). `B`'s leaf token remained cryptographically intact—`B` had not re-signed anything, and the leaf's signature over its attenuation predicate still verified against `B`'s key, which was untouched. The resource server's verifier did the following, in order: (1) verified leaf signature against `B`'s public key — pass; (2) verified that `B`'s delegation edge from `A` was signed by `A` — pass, because the signature was made before revocation and signatures do not expire on revocation; (3) checked the leaf's revocation record by leaf identifier — not present, so pass; (4) checked `B`'s revocation record — not present, so pass. The request was authorized with scope `s1`. The revoked `A` was never consulted. The revocation of `A` propagated to nothing downstream because nothing downstream re-validated `A`. In a second run, the verifier was made to walk the chain and check each hop's revocation record; it then rejected correctly, but *only* because the walk was implemented. The default library path did not walk. A subtler variant: when `A` was revoked *and* the revocation record was published with a `notAfter` equal to the revocation time, a naive chain-walker that compared the leaf's issuance time to each hop's `notAfter` would still accept, because the leaf was issued before `A`'s revocation. The correct predicate is "reject if any ancestor is revoked as of *now*," not "as of leaf issuance." Delegation is not grandfathered by earlier issuance; a revoked delegator invalidates every capability it ever minted, regardless of mint time. ## Condition of Validity The local-revocation-check behavior is only sound under the assumption that **no credential in the chain is ever revoked while a descendant is still live**. That assumption is false in exactly the scenarios revocation exists for: key compromise, scope over-grant discovered post-hoc, and agent decommissioning. Therefore the local check is valid only for single-hop tokens, or for chains where the deployment guarantees atomic cascade revocation (revoking an ancestor also revokes all descendants, and the revocation publisher performs that closure before any verifier sees the ancestor's record). That cascade guarantee is the load-bearing condition. If the revocation publisher computes transitive closure and emits revocation records for every descendant, then checking the leaf's own record is sufficient—the leaf's record will be present. If the publisher emits only the directly-revoked credential (the common implementation, because closure requires indexing the delegation graph), the local check silently passes revoked chains. The condition of validity is thus not "the verifier checks revocation" but "the verifier checks revocation *and* the publisher performs cascade closure, or the verifier walks the chain." One of the two must hold; neither is the default. ## Decision Boundary The boundary is crossed at the moment a chain has depth ≥ 2 and any non-leaf credential is revoked. At depth 1, local and transitive checks coincide. At depth ≥ 2 with a revoked intermediate, they diverge, and the divergence is silent: no signature fails, no expiry trips, no audience mismatch fires. The system returns `authorized` where it should return `denied`. The decision rule for an implementer: **if the token format permits depth ≥ 2, the verifier MUST walk the chain and check every hop's revocation record as of verification time, OR the deployment MUST guarantee and document cascade closure at the publisher.** Absent one of these, treat the verifier as unsound for multi-hop tokens, regardless of what the leaf signature says. Do not attempt to infer soundness from a passing signature check; signature validity is orthogonal to chain validity by construction. ## Operational Effect The observable effect is a window—bounded below by zero and above by the publisher's closure latency, or unbounded if no closure is performed—during which a compromised or decommissioned intermediate continues to authorize traffic through its descendants. Because the leaf is valid, the traffic is indistinguishable from legitimate traffic at the resource server; audit logs show a valid chain and a valid signature. Post-incident forensics must reconstruct the delegation graph to discover that the authorizing path traversed a revoked node, because the authorization event itself records only the leaf identifier. The practical mitigation is to make chain-walking the default in the verifier and to treat a missing ancestor revocation record as a hard failure rather than an implicit pass. A verifier that cannot resolve an ancestor's revocation status must fail closed; the cost is availability during revocation-publisher outages, and that cost is the correct trade against silently honoring revoked delegation. ```python import hashlib from dataclasses import dataclass from typing import Optional class DelegationChainRevoked(RuntimeError): """Raised when a leaf capability token's delegation chain contains a revoked ancestor.""" pass @dataclass(frozen=True) class Credential: credential_id: str delegator_id: Optional[str] # None for root issued_at: int signature_valid: bool # signature over the delegation edge / attenuation predicate def _revocation_status(credential_id: str, revocation_index: dict[str, int]) -> bool: """Return True if the credential is revoked as of now. Missing entry => not revoked.""" return credential_id in revocation_index def verify_leaf_authorization( leaf: Credential, chain: dict[str, Credential], revocation_index: dict[str, int], require_ancestor_status: bool = True, ) -> bool: """ Verify a leaf capability token by walking the full delegation chain. The leaf signature alone is NOT sufficient: an intermediate ancestor may be revoked while the leaf signature remains intact. Fail closed if an ancestor's revocation status cannot be resolved and require_ancestor_status is set. """ if not leaf.signature_valid: raise DelegationChainRevoked( f"leaf {leaf.credential_id}: signature invalid" ) cursor: Optional[Credential] = leaf visited: set[str] = set() while cursor is not None: if cursor.credential_id in visited: raise DelegationChainRevoked( f"cycle detected at {cursor.credential_id}" ) visited.add(cursor.credential_id) # Check revocation as of NOW for every hop, not just the leaf. if _revocation_status(cursor.credential_id, revocation_index): raise DelegationChainRevoked( f"delegation chain revoked at ancestor {cursor.credential_id} " f"(leaf {leaf.credential_id})" ) if cursor.delegator_id is None: break # reached root parent = chain.get(cursor.delegator_id) if parent is None: if require_ancestor_status: raise DelegationChainRevoked( f"cannot resolve ancestor {cursor.delegator_id} for " f"leaf {leaf.credential_id}; failing closed" ) break cursor = parent return True ```