feat(delegation): let an issuer revoke a grant before not_after - #189
Merged
Merged
Conversation
A delegated grant could only lapse at not_after. Inside a still-valid window there was no way for the delegator to take it back. Add signed revocation statements. The issuer of a credential, or any issuer above it in the chain, signs a statement naming the credential by the SHA-256 of its canonical body, using the existing RFC 8785 helper. verify_chain takes an optional RevocationSnapshot and refuses a chain containing a revoked hop with CREDENTIAL_REVOKED, which also refuses every grant beneath it. A delegate cannot revoke upward, a statement from an unrelated key has no effect, nothing can un-revoke, and a snapshot containing a forged or unsigned statement is refused as a whole with INVALID_REVOCATION. Without a snapshot verification is unchanged and still offline, and verify_chain now returns a RevocationStatus that says revocation was not checked. ChainResult, PeerResult and the verify-chain / verify-dag output carry it. max_revocation_staleness fails closed with REVOCATION_STATUS_UNKNOWN when the snapshot is missing or older than the bound. PeerNode takes a revocation_source callable consulted on every call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
threat-model.md said delegated authority could not be withdrawn early. Replace that with what now exists: who may revoke, the cascade, the not_checked status an offline verifier reports, the optional staleness bound, and what is still out of scope (distributing revocation data, and a verifier with no snapshot learning of a revocation). Add the statement format and snapshot semantics to delegation-chain.md, the three new error codes, the verification-library parameters, an informative note under profile P-4, and LIMITATIONS and CHANGELOG entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
imran-siddique
requested review from
a team,
carloshvp and
zohebk8s
as code owners
September 18, 2026 19:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
docs/spec/threat-model.md:46 on main says delegated authority cannot be actively withdrawn. Inside a still-valid not_before / not_after window, a compromised delegate kept its grant until not_after.
This adds signed revocation statements (src/ca2a_runtime/delegation/revocation.py). A statement names the revoked credential by the SHA-256 of its canonical body, the same bytes its issuer signed, and carries revoker, issued_at and a type tag. Signing reuses the RFC 8785 helper in ca2a_runtime.canonical.
verify_chain takes an optional RevocationSnapshot. A statement counts against hop i only if its revoker is the issuer of hop i or of an earlier hop. The subject of hop i is not in that set, so a delegate cannot revoke upward, and an unrelated key does nothing. A revoked hop fails the whole chain with CREDENTIAL_REVOKED, which takes every grant beneath it down too. Nothing can un-revoke: the wire object is strict and has no field for it, and any effective statement is enough.
A snapshot with an unsigned or forged statement is refused as a whole with INVALID_REVOCATION. I chose that over dropping the bad entry because a tampered feed is untrustworthy, and silently dropping entries would let tampering act as an un-revocation.
P-4 offline verification is kept by making revocation data a local input, like trusted_root_issuers. Reading a snapshot contacts nobody. With no snapshot, verification behaves as before, and verify_chain now returns a RevocationStatus with checked=False. ChainResult, PeerResult and the verify-chain / verify-dag JSON carry it as "revocation": "not_checked", so a pass from an offline verifier cannot be read as "not revoked".
max_revocation_staleness is optional and off by default, because a default bound would fail every offline check that has no revocation data. When set, a missing snapshot or one whose as_of is older than the bound fails closed with REVOCATION_STATUS_UNKNOWN. Revocation is checked before staleness: a stale snapshot can still prove a hop revoked, it just cannot prove one is not. With at_time (an audit), only statements with issued_at at or before that time count. Live verification applies every statement it holds.
PeerNode takes a revocation_source callable, called on every request.
Out of scope, and the docs say so:
Docs updated: threat-model.md (the paragraph at :46 and a new attack row), delegation-chain.md (statement format, authority, snapshot semantics, two new invariant rows), error-codes.md, verification-library.md, an informative note under profile P-4, LIMITATIONS.md and CHANGELOG.md. Documented CLI outputs now show the revocation field. The repo has no schemas/ directory.
Testing, on a fresh Python 3.12 venv built the way ci.yml builds it (pip install --require-hashes -r requirements/dev.txt, then --no-deps -e .), so agentrust-trace 0.10.0 and agent-manifest 0.12.0 came from PyPI:
Not run locally: Python 3.11 and 3.13, and Linux. The docs build, staged the way docs.yml stages it, reports no warnings on the changed pages.
🤖 Generated with Claude Code