Skip to content

feat(delegation): let an issuer revoke a grant before not_after - #189

Merged
imran-siddique merged 2 commits into
mainfrom
fix/delegation-revocation
Sep 18, 2026
Merged

imran-siddique merged 2 commits into
mainfrom
fix/delegation-revocation

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

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:

  • cA2A does not publish or fetch revocation data. That is the deployment's job.
  • A verifier with no snapshot still cannot learn of a revocation. It is now told it did not check.
  • as_of is asserted by whoever supplies the snapshot and is not signed. A supplier can withhold a statement but cannot forge one.
  • ca2a start does not load a snapshot.

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:

  • bandit -r src/ -c pyproject.toml: no issues. pip-audit: exit 0.
  • ruff check src/ tests/ and ruff format --check src/ tests/: clean.
  • mypy src/ca2a_runtime/ src/ca2a_verify/: no issues in 46 files.
  • pytest tests/unit/ tests/conformance/ -v --tb=short --cov=src: 671 passed, 2 skipped, coverage 89.52%. Main was 633 passed, 2 skipped in the same venv. tests/unit/test_revocation.py adds 38 cases: leaf revoke, middle-link cascade, delegate revoking upward, forged and unsigned statements, un-revoke attempts, not_checked without a snapshot, stale snapshot under the policy, the CLI and PeerNode.handle.
  • Governance job: gen_agt_evidence.py plus agt verify --evidence agt-evidence.json in an isolated venv passed.

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

imran-siddique and others added 2 commits September 18, 2026 11:42
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>

@Qiang-Xu Qiang-Xu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@imran-siddique
imran-siddique merged commit f018c0e into main Sep 18, 2026
17 checks passed
@imran-siddique
imran-siddique deleted the fix/delegation-revocation branch September 18, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants