# Which invariants prevent a delegated authorization chain from silently widening scope at the second hop **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF-style agent identity attestation where a root principal issues a verifiable credential (VC) to agent A, and A delegates a derived credential to agent B via a chain of attenuation. Does not apply to single-hop issuance where the root principal directly signs the leaf credential, nor to bearer-token systems without cryptographic delegation. ## Core Distinction A delegated authorization chain is *sound* when every hop strictly attenuates the capability set: the child credential's authority set is a subset of the parent's, and the child's subject is bound to the parent's subject through a signed delegation proof. A chain is *silently widened* when the child credential is syntactically valid, verifies against the root's public key, and passes naive signature checks — yet grants authority the root never intended, because the attenuation check compared the wrong field, or because the delegation proof was replayed across a different resource, or because scope was expressed in two representations (a string and a structured claim) that were compared only in one. The non-obvious boundary is this: signature validity is orthogonal to attenuation validity. A credential chain can be cryptographically pristine and semantically over-broad at the same time. Every hop that verifies signatures without independently re-deriving the intersection of parent and child authority is a widening surface. ## What Was Observed In a multi-hop AAIF deployment, a root principal issued a VC to a scheduling agent with scope `{"actions": ["read:calendar", "write:calendar"], "resources": ["cal:team-a/*"]}`. The scheduling agent delegated to a sub-agent a credential whose structured `resources` claim was `["cal:team-a/alice"]` — properly narrowed — but whose legacy `scope` string field, retained for backward compatibility with an older verifier, was copied verbatim from the parent as `"read:calendar write:calendar cal:team-a/*"`. The sub-agent's verifier checked the structured claim and passed. A downstream resource server, running the older verifier, checked only the string field and granted write access to all of `cal:team-a/*`. Neither verifier was individually broken. The failure lived in the gap between two representations of the same semantic field, and in the delegation step that attenuated one representation while copying the other. The chain was signature-valid at every hop. The attenuation invariant was violated because attenuation was never checked as a total function over *all* scope representations. A second observed instance: a delegation proof signed by agent A carried an audience claim naming the resource server, but the proof was not bound to the specific parent credential's hash. A was able to reuse the same delegation proof to mint a second child credential against a *different*, broader parent credential it also held. The proof verified — it was genuinely signed by A — but it authorized a hop the root never sanctioned, because the proof did not commit to which parent it attenuated. ## Condition of Validity The chain is sound only when all of the following hold simultaneously at every hop: 1. **Total attenuation.** For every representation of scope in the parent (structured claims, string fields, extension fields, header-derived scopes), the child's corresponding representation must be a subset. Attenuation is a conjunction over representations, not a disjunction or a first-match. 2. **Parent commitment.** The delegation proof must include a cryptographic digest of the exact parent credential it attenuates. A proof not bound to its parent is a bearer capability, not a delegation. 3. **Subject binding.** The child's subject key must be bound to the delegation proof's signer, and the proof's signer must equal the parent's subject. Transitive identity is established by the chain of subject bindings, never by a name field. 4. **Audience and resource narrowing.** Any audience or resource restriction must be non-increasing along the chain. Widening an audience at any hop is a widening of authority even if actions are narrowed. 5. **No representation collapse.** If a verifier cannot interpret a scope representation present in the credential, it must reject, not ignore. Ignoring an unrecognized scope field is how the older verifier above became the widening surface. The invariant is *not* valid when any hop's verifier trusts a signature without re-deriving the intersection, or when the chain format permits a scope representation the attenuation logic does not enumerate. ## Decision Boundary When a verifier encounters a credential chain, the decision boundary is between *verifying the chain* and *authorizing the request*. These must be separate decisions with separate failure modes: - If signatures verify but attenuation cannot be proven over all representations → reject with a chain-integrity error. Do not fall back to "trust the root, ignore the hop." - If the delegation proof lacks a parent commitment → reject. Do not accept on the grounds that the signer is trusted; a trusted signer can still mis-delegate. - If a scope representation is unrecognized → reject. The safe default for an unknown scope field is denial, because the field may encode authority the attenuation logic never saw. - If the chain is sound but the request exceeds the leaf's authority → that is an ordinary authorization denial, not a chain error. Keep the two error classes distinct so operators can tell a misconfigured delegator from an over-reaching agent. The boundary is crossed the moment a system treats "the signature checked out" as sufficient grounds to skip the attenuation re-derivation. That substitution is the root cause of every silent widening observed. ## Operational Effect Enforcing total attenuation and parent commitment converts silent widening into loud, attributable failures. In the deployment above, adding a parent-digest commitment to delegation proofs and a representation-completeness check to the attenuation logic caused the sub-agent's malformed delegation to fail at issuance rather than at the downstream resource server — moving detection from the point of damage to the point of cause. The cost is that every delegator must now enumerate all scope representations it emits, and every verifier must reject unknown representations; the benefit is that a compromised or buggy intermediate agent can no longer mint authority broader than it holds, even though it can still mint authority narrower than it holds. The residual risk is representational drift: a new scope field added by one party and not another reopens the gap. The only durable mitigation is to make the scope schema closed — unknown fields are errors — and to require that the attenuation function be total over the schema by construction, so that adding a field without adding attenuation logic fails closed. ```python import hashlib from dataclasses import dataclass, field from typing import FrozenSet class DelegationWideningError(RuntimeError): """Raised when a delegated credential chain widens authority at any hop.""" @dataclass(frozen=True) class Credential: subject: str actions: FrozenSet[str] resources: FrozenSet[str] audiences: FrozenSet[str] scope_string: str parent_digest: str = "" # digest of the parent credential this attenuates def digest(self) -> str: h = hashlib.sha256() h.update(self.subject.encode()) h.update(b"|".join(sorted(self.actions))) h.update(b"|".join(sorted(self.resources))) h.update(b"|".join(sorted(self.audiences))) h.update(self.scope_string.encode()) h.update(self.parent_digest.encode()) return h.hexdigest() def _parse_scope_string(scope: str) -> tuple[FrozenSet[str], FrozenSet[str]]: # Legacy string form: "action action resource/pattern ..." actions, resources = set(), set() for tok in scope.split(): if "/" in tok or "*" in tok: resources.add(tok) else: actions.add(tok) return frozenset(actions), frozenset(resources) def _subset(child: FrozenSet[str], parent: FrozenSet[str], label: str) -> None: extra = child - parent if extra: raise DelegationWideningError( f"{label} widened at hop: child grants {sorted(extra)} " f"not present in parent {sorted(parent)}" ) def verify_attenuation(child: Credential, parent: Credential) -> None: """Enforce total attenuation across every scope representation.""" if child.parent_digest != parent.digest(): raise DelegationWideningError( "delegation proof is not bound to this parent credential" ) _subset(child.actions, parent.actions, "actions") _subset(child.resources, parent.resources, "resources") _subset(child.audiences, parent.audiences, "audiences") # Attenuation must also hold over the legacy string representation. child_actions, child_resources = _parse_scope_string(child.scope_string) _subset(child_actions, parent.actions, "scope_string actions") _subset(child_resources, parent.resources, "scope_string resources") # Any structured action/resource must also appear in the string form; # a mismatch means a representation the attenuation logic cannot reconcile. if not child.actions <= child_actions: raise DelegationWideningError( "structured actions not represented in scope_string; " "representation collapse detected" ) def verify_chain(chain: list[Credential]) -> None: if not chain: raise DelegationWideningError("empty chain") for parent, child in zip(chain, chain[1:]): verify_attenuation(child, parent) ```