Skip to content

fix(verify): enforce key revocation at verification time (#76) - #113

Merged
imran-siddique merged 1 commit into
agentrust-io:mainfrom
zohebk8s:fix/76-enforce-revocation-at-verify-time
Aug 3, 2026
Merged

imran-siddique merged 1 commit into
agentrust-io:mainfrom
zohebk8s:fix/76-enforce-revocation-at-verify-time

Conversation

@zohebk8s

@zohebk8s zohebk8s commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Closes #76.

verify_record() checked signature and freshness but never consulted revocation or transparency state, so a record signed by a revoked or compromised key kept verifying. This is a gap against the spec's own normative text, not a new requirement: §3.2.1 already says "Verifiers MUST consult current revocation status at verification time".

A signature stays cryptographically valid forever and nothing inside a record can retract the key that made it, so the verifier is the only place withdrawal of trust can be applied.

The revocation store

verify_record(..., revocation=...) accepts either form:

# A revocation list the caller already holds.
verify_record(record, trusted_jwk, revocation={"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"})

# Or a live CRL / status endpoint / SCITT lookup.
verify_record(record, trusted_jwk, revocation=lambda key_id: crl.is_revoked(key_id))
Outcome Result
Key listed as revoked Rejected (ValueError)
Store raises (endpoint down, timeout) Rejected; an unavailable source is not evidence a key is unrevoked
Key absent from the store Verification continues
No revocation passed Check skipped; offline verification, unchanged

Failing closed on an unreachable store is the part that matters: if a lookup failure fell through to "verified", an attacker holding a revoked key would only need to make the status endpoint unreachable.

Two design points worth review

The check reads the trusted key, not record["cnf"]["jwk"]. The embedded key is attacker-controlled until the signature verifies, so keying the lookup on it would let a revoked issuer present an unlisted thumbprint and pass. There is a test pinning this.

Identifiers are the RFC 7638 JWK Thumbprint or kid. Thumbprints work when an issuer publishes no kid, and are stable across JWKs differing in optional members. jwk_thumbprint() is exported so a caller can key a list on the same identifier the verifier derives; it is pinned against the RFC 8037 Appendix A.3 known-answer vector.

Scope

Deliberately not in this PR:

Documented rather than left implied: docs/verification.md opens with "independently verifiable offline", which is true and is exactly why non-revocation is the one thing it cannot establish. That page and LIMITATIONS.md now say so directly.

Type of change

  • Editorial (typo, link fix, clarification — no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

None of the listed categories fit, so I have left them unticked rather than claim one inaccurately. This is a library and docs change that implements an existing normative MUST. No spec text, schema, or record field is modified. Happy to tick whichever box you consider correct.

Spec section

3.2.1 (Signing and key management), implemented rather than modified. No spec text changed.

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated
  • Breaking changes marked with <!-- CHANGED: #NNN — description --> in spec text (not applicable, no breaking change)
  • Backward compatibility statement included: additive keyword argument defaulting to None, so offline verification is byte-for-byte unchanged

Verification

  • 105 tests pass, 14 new. Every new line covered (sign.py misses are pre-existing).
  • ruff check src tests and mypy src/agentrust_trace (strict) clean.
  • Backward compatibility checked against a downstream consumer: the cMCP suite passes unchanged with the patched package installed, 893 passed and 8 skipped, before and after.

…o#76)

Spec 3.2.1 requires that "Verifiers MUST consult current revocation status
at verification time", but verify_record() checked only the signature and
freshness. A record signed by a revoked or compromised key kept verifying,
because a signature stays cryptographically valid forever and nothing inside
a record can retract the key that made it. The verifier is the only place
that withdrawal of trust can be applied.

Add a revocation store to the verify path. It accepts either a container of
revoked key identifiers, for a list the caller already holds, or a callable
performing a live CRL, status-endpoint, or SCITT lookup. A listed key is
rejected, and so is a store that cannot answer: an unavailable revocation
source is not evidence that a key is unrevoked, and treating a lookup
failure as a pass would mean an attacker holding a revoked key only has to
make the endpoint unreachable.

Keys are identified by RFC 7638 JWK Thumbprint or kid. The check reads the
trusted key rather than record["cnf"]["jwk"], which is attacker-controlled
until the signature verifies; keying the lookup on the embedded key would
let a revoked issuer present an unlisted thumbprint and pass.

Also exports jwk_thumbprint(), so a caller can key a revocation list on the
same identifier the verifier derives. Pinned against the RFC 8037 A.3
known-answer vector.

Additive and backward compatible: revocation defaults to None, which leaves
verification purely offline and byte-for-byte unchanged. That mode cannot
prove non-revocation, which LIMITATIONS.md and docs/verification.md now say
plainly rather than leaving implied by "independently verifiable offline".
No normative text, schema, or record field changed.

Verified: 105 tests pass (14 new), ruff and mypy clean, all new lines
covered. Downstream cmcp suite passes unchanged against the patched
package, 893 passed before and after.

Refs agentrust-io#76

Signed-off-by: zohebk8s <zoheb.shaik7@gmail.com>
@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential NONE
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Jul 30, 2026
@zohebk8s

Copy link
Copy Markdown
Contributor Author

Fork CI is green on this exact commit (283383d), both matrix legs:
https://github.com/zohebk8s/trace-spec/actions/runs/30584031843

test (3.11) and test (3.12): lint, type check, and tests all pass.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the diff carefully, including against the individual-contributor spec policy. This is not a normative change: spec/trace-v0.2.md and schema/ are untouched, and the changed files are the reference implementation plus non-normative docs. It implements normative text that §3.2.1 already carries rather than adding a requirement, so the policy that reserves spec changes to known orgs does not apply here.

On the substance, the security-critical decisions are all the right way round:

  • The check is keyed on the trusted key, never record["cnf"]["jwk"]. That is the one detail that decides whether this is a real control or theatre, since the embedded key is attacker-controlled until the signature verifies, and keying on it would let a revoked issuer present an unlisted thumbprint. The allow_embedded_key=True case is called out rather than glossed.
  • A store that cannot answer is a rejection, not a pass. An unreachable CRL is not evidence a key is unrevoked.
  • revocation=None stays the default, so offline verification is unchanged and the feature is additive. LIMITATIONS.md and docs/verification.md now say plainly that offline mode proves "validly signed by this key", not "this key is still trusted". That honesty is the part I care most about.
  • jwk_thumbprint hashes only the RFC 7638 required members per kty, so it is stable across JWKs differing in kid/alg/use. Using JCS for the serialization is fine given member names are ASCII, and the docstring already notes why that is byte-identical to RFC 7638 §3.3 ordering. Omitting oct is correct for a module that only handles public confirmation keys.
  • Matching on thumbprint or kid covers issuers that publish neither consistently.

Placement before the signature check is right: the trusted key's status does not depend on the record, so there is nothing to gain by verifying first.

Only failing check is the maintainer-hold gate.

@imran-siddique
imran-siddique merged commit 4f7e84f into agentrust-io:main Aug 3, 2026
1 of 2 checks passed
imran-siddique added a commit that referenced this pull request Sep 25, 2026
…rFuzzLite (#418)

- sign._b64url_decode accepts only canonical unpadded base64url, so one
  signature has one spelling (Trust Record, JWK x, provenance, bridge and
  revocation-bundle signatures).
- content_marking refuses served record bytes with duplicate members and
  keeps deep nesting and oversized ports on ContentMarkingError.
- RecursionError from deeply nested cnf.jwk, tool calls and provenance
  records is reported on each module's documented error type.
- tool_catalog_hash raises ProvenanceError (still an UnanchorableValue) for
  a catalog with no anchor form.
- ClusterFuzzLite targets over the record, bundle, provenance, bridge,
  content-marking and canonicalization paths (Scorecard FuzzingID #42).
- runtime-evidence.yml installs from hash-pinned locks (Scorecard #113,
  #114); requirements/dev.txt recompiled --universal with the same pins.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: enforce revocation/transparency at verify time

3 participants