Repository navigation
fix(verify): enforce key revocation at verification time (#76) - #113
imran-siddique merged 1 commit into
Conversation
…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>
|
🟡 Contributor Check: MEDIUM
Automated check by AgenTrust Contributor Check. |
|
Fork CI is green on this exact commit (283383d), both matrix legs: test (3.11) and test (3.12): lint, type check, and tests all pass. |
imran-siddique
left a comment
There was a problem hiding this comment.
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. Theallow_embedded_key=Truecase 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=Nonestays the default, so offline verification is unchanged and the feature is additive.LIMITATIONS.mdanddocs/verification.mdnow 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_thumbprinthashes only the RFC 7638 required members perkty, so it is stable across JWKs differing inkid/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. Omittingoctis correct for a module that only handles public confirmation keys.- Matching on thumbprint or
kidcovers 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.
…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>
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:ValueError)revocationpassedFailing 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 nokid, 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:
revocationdefaults toNonerather than being required. Making it mandatory is a breaking API change for every consumer (cMCP, ca2a) and is better decided alongside [pre-v1.0] Key revocation: TraceRevocation claim + entry-ID anchor #67, which designs theTraceRevocationclaim and the entry-ID anchor. This PR gives the verify path the hook; [pre-v1.0] Key revocation: TraceRevocation claim + entry-ID anchor #67 can decide what feeds it.Documented rather than left implied:
docs/verification.mdopens with "independently verifiable offline", which is true and is exactly why non-revocation is the one thing it cannot establish. That page andLIMITATIONS.mdnow say so directly.Type of change
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
git commit -s)CHANGELOG.mdupdated<!-- CHANGED: #NNN — description -->in spec text (not applicable, no breaking change)None, so offline verification is byte-for-byte unchangedVerification
sign.pymisses are pre-existing).ruff check src testsandmypy src/agentrust_trace(strict) clean.