spec: anchor key revocation to log entry ordering, not iat (#67) - #187
Conversation
The editorial decision recorded on #67, landed. Section 3.2.1 required verifiers to "consult current revocation status at verification time", which contradicts the offline-verification property section 3.3 is built on, and the document said both things. WHY NOT A TIMESTAMP The intuitive rule is to reject a record from a revoked key when its iat falls after the compromise. A compromised record-signing key also signs iat, so whoever holds it backdates the record and the rule passes. Any revocation rule anchored to a value the compromised key controls is defeated by the compromise it exists to contain, and no clock-skew tolerance changes that. Section 3.2.3 anchors to the SCITT inclusion entry ID instead. Entry IDs are monotonic and bound to the Merkle structure, so an attacker cannot choose one for a record submitted after the log moved past it, and cannot reorder committed entries. Ordering survives the compromise; a timestamp does not. WHAT LANDS TraceRevocation/1.0, with the rule that a record from a revoked key is valid iff its inclusion entry ID is at or below last_valid_entry_id on the log the statement names. Entry IDs from a different log are not comparable and cannot satisfy it. Signing-key independence is a MUST: a revocation statement for K is signed by a key above K in the 3.2.1 hierarchy or by a recovery key with an independent compromise domain. A statement K could sign for itself hands whoever stole K the ability to set last_valid_entry_id wherever they like, which turns the mechanism into a tool for the attacker. Distribution is a cached signed bundle with valid_until, anchored in the same log as the records it governs, which is what keeps 3.3's no-callback property. The rejected alternative was a well-known status endpoint: it would require a callback at verification time and withdraw the property the specification exists to provide. Two honest states rather than a silent skip. An expired bundle reports the record as unverified for revocation, not verified. A verifier with no bundle reports that it performed no revocation check. Neither may be presented as an affirming appraisal, because "I could not check" and "I checked and it was fine" are the two answers a relying party most needs told apart. FALLBACK A record with no usable inclusion entry ID has no external anchor, so nothing places it before or after the compromise. Revocation is binary for it: reject every record from the key. That is not a lesser mode chosen for convenience, it is what the absence of an anchor costs, and it is already the behaviour for deployments carrying no receipts. SCHEMAS schema/trace-revocation.json and schema/trace-revocation-bundle.json. Both closed to unknown fields, because revocation is the one place where an invented field could read as narrowing the revocation. revoked_at exists and is explicitly informational: requiring it would invite an implementation to treat it as the boundary, which is the defect this section is about. A statement keeps its own signature and signer inside the bundle, so a bundle assembler cannot add a revocation it was not authorised to issue. DOCS docs/verification.md quoted the 3.2.1 sentence being replaced, so it would have become false. It now explains the entry-ID boundary and the two offline states, with every requirement keyword attributed to 3.2.3 per the guard in tests/test_requirement_keywords_are_attributable.py, and states plainly that verify_record()'s current store implements the binary fallback rather than the entry-ID-scoped rule. NOT IN SCOPE HERE Carrying the entry ID through verify_record(), and aligning verifier revocation-status output with the PIC binary-revocation vectors. Both are implementation work against the section this lands. VERIFICATION 11 new schema-pair tests. Full suite 489 passed, 1 skipped, including the docs keyword-attribution guard. Refs #67, #46 Signed-off-by: Imran Siddique <imran.siddique@opaque.co> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lywinged
left a comment
There was a problem hiding this comment.
Ran it first: 489 passed, 1 skipped, matching your evidence block. Read #67 and its thread before the diff, since the last time I reviewed an implementation without reading the issue behind it I missed the thing that mattered.
The iat argument is the part I would keep even if everything else changed. "A compromised record-signing key also signs the iat field" is one sentence that retires an entire class of proposed rule, and stating that no tolerance setting fixes it closes the obvious follow-up before someone spends a release on it. Signing-key independence is the other half and it is stated as a MUST rather than left implied — a statement K can sign for itself is exactly the shape that converts a safety mechanism into an attacker's tool.
Five things, in the order I would fix them.
1. The section says what a verifier must not report, and the enum has no seat for what it must.
"Neither may be reported as an affirming appraisal" appears four times across the spec text, the doc and a schema description, and nothing says which value to use instead. appraisal.status is closed at four, and docs/verification.md already defines them:
affirming |
All evidence passed verifier appraisal |
warning |
Evidence passed but with conditions |
contraindicated |
Evidence failed — treat as untrusted |
none |
No appraisal performed (software-only Level 0) |
An expired bundle is not evidence failing, so contraindicated misreports it. One check being unavailable is not "no appraisal performed", so none misreports it too. warning is the only one left and it says "evidence passed", which is the thing the section is trying to prevent a verifier from claiming. Two conformant verifiers will pick differently on the same record, which is the failure mode §3.2.3 exists to close, arriving through the appraisal field instead of through iat.
The shape of the answer is already merged, in #173, and it is not a status value. Depth that could not be resolved is recorded in a dedicated field — provenance_depth_verified — and appraisal.status moves only when that falls below a configured floor: "A verifier whose configured floor is not met by provenance_depth_verified sets appraisal.status to contraindicated." Record what was achieved; let policy decide whether it is enough.
§3.2.3 has the sentence for that and nowhere to put it. "Verified against revocation bundle valid at T" is exactly a provenance_depth_verified-shaped fact, and there is no field for it, so it lives in prose and every implementer invents their own place.
This is the fourth surface asking the same question, and the first three answered it differently. #173 answered it for build_provenance with a recorded field plus a floor. This section answers it for revocation in prose with no field. #184 answers it for delegation links with a classification of its own (unverifiable, distinct from both invalid kinds), on a different axis entirely. And a fourth was raised on #66 this morning: whether appraisal.policy_ref falls under the same resolution discipline. It is the same question again, and it does not have a per-object answer — which is why I am raising it here rather than answering it there.
That question has an answer only if there is one rule. Right now a verifier that cannot resolve a cited object reports a lower depth, or prose, or a third classification, or nothing, depending on which object it failed to resolve. I do not think that is a decision anyone made — each surface reached for what fit, and none cite each other.
Worth settling once, above all four: when a record cites something a verifier cannot resolve, what does the verifier record, and where. If the answer is #173's pattern — a field for what was achieved, a floor that turns it into a status — then this section needs a field, #184 needs to say whether its unverifiable is the same axis or a different one, and the #66 question answers itself rather than becoming a fifth convention.
2. "The applicable revocation statement" is singular, and a bundle can carry two.
bundle["statements"] = [stmt(last="100", after="101"), stmt(last="5", after="6")]
# same compromised_key_id, same log — validates against trace-revocation-bundle.jsonBundle-level log_id closes the cross-log case cleanly, so this is the one left: within one bundle, on one log, two statements for one key with different cutoffs are representable and the verifier rule does not say which applies. Not an attack — the bundle is signed — but a re-issued statement or two merged bundles produce it operationally, and then two verifiers return different answers from identical evidence.
One sentence fixes it either way: lowest last_valid_entry_id wins, which fails closed, or the schema forbids a repeated compromised_key_id within a bundle.
3. revoked_after_entry is signed, redundant, and nothing can check it.
stmt(last="100", after="7") # validatesThe verifier rule reads only last_valid_entry_id. No test asserts the two agree, and the schema cannot express the successor relation: both fields are bare strings with no pattern, so "the next entry ID" has no meaning a validator can check. So a statement can be correctly signed, schema-valid, and internally contradictory — in the field whose stated purpose is to be the one a human reads.
revoked_at got the treatment this needs: "Informational only. It is deliberately NOT the boundary." revoked_after_entry has no such line, and it is the more dangerous of the two because it looks like a boundary. Either give it the same sentence, drop it, or require a verifier to reject a statement where the pair disagrees under the profile's successor function.
4. A new normative rule with no vectors.
test_revocation_schema.py is eleven tests and they are all about the shape of the schema documents — required fields, additionalProperties, the algorithm set. Nothing exercises the verifier rule the section defines. That the rule is not implemented in verify_record() yet is not an obstacle to vectors; a vector set is the executable form of the text, and it is most useful before an implementation exists rather than after.
There is material for it already in #67's own thread: madeinplutofabio posted six scenarios there in June and asked that the log_id same-log constraint be carried into them, and that constraint is now in the text. The cases that would earn their place: an entry ID equal to last_valid_entry_id, one greater, one from a different log, a record with no receipt at all, an expired bundle, an empty-statements bundle, and a statement signed by the key it revokes.
I have the machinery for this from the delegation corpus and would rather build it than ask for it — say the word and it comes as a separate PR against this branch or after it, whichever you prefer.
5. The bundle's log_id description promises an invariant the schema does not enforce.
I had this in the review as praise until I tried to verify it. The field reads:
"The transparency log every statement in this bundle refers to. One log per bundle, so an entry-ID comparison can never be made across logs by accident."
But a statement inside a bundle carries its own log_id, and nothing ties the two together:
bundle = {"log_id": "log-1", "statements": [stmt(log="log-1"), stmt(log="log-DIFFERENT")]}
# validates against trace-revocation-bundle.json"Never by accident" is exactly the accident this permits. A description that asserts a property the schema does not implement is worse than one that stays quiet, because it is believed — I believed it, and was about to say so in writing.
The fix is small and the schema can express it: require each statement's log_id to equal the bundle's, or drop log_id from the statement and let the bundle be the only place it lives. The second is cleaner and makes the invariant true by construction, which is what the description already claims.
One cross-reference rather than a finding. #184's D-10 draws the same line on the delegation surface: a link naming a digest algorithm the verifier cannot compute makes the chain unverifiable rather than invalid, because reporting a failure for something nobody looked at is a finding nobody made. Your "unverified for revocation" is the same distinction. Two sections arriving at it independently is evidence the distinction is real; two sections naming it differently is how it stops being one rule. Worth deciding while both are open.
The iat argument and the independence requirement are the substance and both are right. Not approving yet, for (1) and (5) — and (1) turned out to be larger than a missing sentence once I went looking for where the answer already exists.
|
Triage on the five, since #67 is a close-out item and an unsorted list is not helpful against a date. |
Brings the branch up to f51e1f7, twenty-one commits on from the base it was opened against. The only conflict is CHANGELOG.md, where both sides added an entry at the top of Unreleased/Added: key revocation from agentrust-io#187 and this branch's delegation profile. Both are kept, the merged change first and the proposal under it, since one describes what landed and the other what is proposed. Nothing in the RFC needed changing. Its section references are its own, and the one external citation, spec/trace-v0.2.md section 3.1, still resolves. The "No revocation" limitation still stands as written: agentrust-io#187 anchors revocation of a record-signing key, and what this profile leaves open is revocation of a delegation credential, which is a different object. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Closes #67. The editorial decision recorded on that issue, landed. Part of the CoSAI WS4 Phase 1 review close-out (ws4 #149) — #67 was one of the trace-spec items the RFC pointed reviewers at.
The contradiction being fixed
§3.2.1 required verifiers to "consult current revocation status at verification time", which contradicts the offline-verification property §3.3 is built on. The document said both things.
Why not a timestamp
The intuitive rule is to reject a record from a revoked key when its
iatfalls after the compromise. A compromised record-signing key also signsiat, so whoever holds it backdates the record and the rule passes. Any revocation rule anchored to a value the compromised key controls is defeated by the compromise it exists to contain, and no clock-skew tolerance changes that.§3.2.3 anchors to the SCITT inclusion entry ID instead: monotonic, bound to the Merkle structure, and not choosable for a record submitted after the log has moved past it. Ordering survives the compromise; a timestamp does not.
What lands
TraceRevocation/1.0— valid iff the record's inclusion entry ID is at or belowlast_valid_entry_idon the log the statement names. Entry IDs from a different log are not comparable and cannot satisfy the rule.last_valid_entry_idwherever they like, which turns the mechanism into a tool for the attacker.valid_until, anchored in the same log as the records it governs. The rejected alternative was a well-known status endpoint: it needs a callback at verification time and withdraws the property the spec exists to provide.Schemas
schema/trace-revocation.jsonandschema/trace-revocation-bundle.json. Both closed to unknown fields, because revocation is the one place an invented field could read as narrowing the revocation.revoked_atexists and is explicitly informational — requiring it would invite an implementation to treat it as the boundary, which is the defect this section is about. Statements keep their own signature and signer inside the bundle, so a bundle assembler cannot add a revocation it was not authorised to issue.Docs
docs/verification.mdquoted the §3.2.1 sentence being replaced, so it would have become false. It now explains the entry-ID boundary and the two offline states, with every requirement keyword attributed to §3.2.3 pertests/test_requirement_keywords_are_attributable.py, and says plainly thatverify_record()'s current store implements the binary fallback rather than the entry-ID-scoped rule.Not in scope here
Carrying the entry ID through
verify_record(), and aligning verifier revocation-status output with the PIC binary-revocation vectors (evidence-sig-block-003-revoked-key,-004-expired-key). Both are implementation work against the section this lands, and #67 scoped them out.Verification
11 new schema-pair tests. Full suite 489 passed, 1 skipped, including the docs keyword-attribution guard.
Separately: running the suite surfaced a 1-in-64 flake in
tests/test_intent_bridge.py::test_tampering_is_rejected[signature]— it "tampers" by replacing the signature's first base64url character withA, which is a no-op when that character is alreadyA, and the untampered signature then verifies. Unrelated to this change; I'll raise it on its own.