Repository navigation
Description
What is wrong
The roadmap already names this pattern, for the sibling format:
Revocation at verification time (
verify_record(..., revocation=...)). §3.2.1
always required it; the verifier did not do it.
The same shape has reappeared in provenance.py, in two places. Neither is a defect in
the sense that something wrong happens — it is that a stated intent has no mechanism
behind it, and the sibling verifier's affordances are absent.
1. issued_at exists to support aging, and nothing ages.
_check_structure requires it and explains why:
raise ProvenanceError(
"issued_at must be a non-negative integer Unix timestamp. A record with no "
"issue time cannot be aged, so a consumer has no way to reject a stale one."
)The field is validated for type and sign, and then never read again. Walking every
Compare node in the module that mentions issued_at gives two: issued_at < 0 in
_check_structure, and issued_at is not None in build_record. The only
time.time() call is build_record stamping the default. Verification never compares
it to anything.
The contrast with the sibling verifier, on the same tree, with no extra arguments
passed to either:
# Trust Record, iat 400 days old
sign.verify_record(signed, jwk)
# ValueError: record is stale: iat is 34560000s old, exceeds max_age_seconds=86400
# Provenance Record, issued_at 400 days old
provenance.verify_record(signed, jwk)
# accepted
provenance.verify_record(signed, jwk, max_age_seconds=86400)
# TypeError: verify_record() got an unexpected keyword argument 'max_age_seconds'2. There is no revocation hook, and the caller-side substitute is not equivalent.
sign.verify_record takes revocation; provenance.verify_record(record, trusted_jwk)
takes nothing. To be precise about what that does and does not mean: Trust Records do
not check revocation by default either — revocation=None skips it, as documented.
So this is not "one checks and one doesn't". It is that one format gives the consumer
the hook and the other does not.
The obvious response is that the caller supplies trusted_jwk and can consult a CRL
themselves before calling. That works less well than it looks, because the rule the
library uses to decide what a key is listed under is private:
def _key_identifiers(jwk): # not exported
identifiers = [jwk_thumbprint(jwk)]
kid = jwk.get("kid")
if isinstance(kid, str) and kid and kid not in identifiers:
identifiers.append(kid)
return identifiersjwk_thumbprint and RevocationStore are both public; _key_identifiers is not. A
caller writing the check by hand reaches for the thumbprint, and misses every entry
listed by kid — which is the conventional way to list one, since that is what kid
is for:
crl = {"acme-2026q3"} # listed by kid
jwk = {**key_to_jwk(key), "kid": "acme-2026q3"}
sign.verify_record(trust_record, jwk, revocation=crl)
# ValueError: signing key is revoked (listed as 'acme-2026q3')
jwk_thumbprint(jwk) in crl
# False — the hand-written check passes the same revoked keySo the caller-side substitute exists but diverges from the library, and it diverges in
the direction of accepting a revoked key.
Two readings I want to address before they come up
"§5 step 3 already covers it — a revoked key is not 'the key you expect for
publisher'." That is a fair reading of the words, but step 3 is a one-time identity
determination, and the same reading would apply just as well to Trust Records, where an
explicit revocation parameter and a fail-closed check were added anyway. Whatever step
3 means, it did not remove the need for that on the other format.
"server-provenance-v1.md says 'Signing follows TRACE v0.2 §3.2 exactly', and §3.2.1
contains the revocation MUST, so it is incorporated by reference." Possibly — I do not
want to claim the spec is silent when that sentence exists. But it is scoped to signing
mechanics ("including the canonicalization"), while §5 (verification) and §7
(conformance) enumerate the verifier's duties and neither mentions revocation or
freshness. A word-boundary search of the whole document for
fresh|stale|expiry|expire|expiration|lifetime|revoked|revocation|max_age|not_after|ttl
returns nothing. If the incorporation is intended, saying so in §5 would settle it.
Spec section or file
src/agentrust_trace/provenance.py (verify_record, _check_structure), measured
against spec/server-provenance-v1.md §5 and §7. Compared throughout against
src/agentrust_trace/sign.py verify_record on the same tree, 576507b.
Deliberately not in scope: content_marking.verify_assertion. It performs no
signature verification at all, by explicit design — "The C2PA manifest signature and the
Trust Record signature are separate checks by separate keys, and this function performs
neither" — so freshness and revocation belong to whoever verifies the record, not to it.
Expected behavior
Two optional parameters on provenance.verify_record, with exactly the semantics
sign.verify_record already documents:
def verify_record(record, trusted_jwk, *,
revocation: RevocationStore | None = None,
max_age_seconds: int | None = None) -> None:Nothing new is needed to implement either — RevocationStore, _check_not_revoked,
_key_identifiers and the freshness comparison all exist in sign.py. Whether
max_age_seconds should default to a value or to None is a real question, and I would
argue None here (see below), unlike Trust Records where 86400 is right.
The revocation half in particular gets cheaper if #149 lands first, which is why this is
filed behind it rather than alongside. That PR brings jwk_thumbprint into this module
so a key is identified by its material rather than its dict shape — and the thumbprint
is the first entry _key_identifiers returns. With that already in place, wiring
_check_not_revoked in introduces no new concept to the module; it reuses the identity
notion #149 establishes. The two changes touch different parts of verify_record and do
not conflict, but the ordering makes the second one smaller.
On the spec side, one step in §5 and one line on issued_at in §3 would make the
consumer's duty explicit, and would settle the incorporation-by-reference question
above.
Impact
I want to be accurate about the size of this rather than inflate it.
A defensible design already exists for omitting freshness, and the issue should
concede it: a provenance record describes an artifact identified by an immutable digest,
much like a package signature, and those are conventionally valid indefinitely with time
handled at the key layer. On that reading, "no default max-age" is a choice rather than
an oversight, and demanding one would be wrong.
Three things keep it alive anyway:
- A record may carry
endpointidentity —urlplusspki_sha256— and that decays.
Keys rotate, domains change hands. The immutability argument coversartifactbut
notendpoint. - The library states the intent itself.
issued_atis required, with an error message
saying why it exists, and the thing it exists for is not implemented anywhere in the
verifier or required anywhere in the spec. - Freshness may be arguable; revocation is not optional under any signature model, and
that is where the missing hook bites hardest.
What is not affected: nothing verifies incorrectly today. A record that should pass,
passes. This is about what a consumer is unable to reject, not about anything being
wrongly accepted as valid.
The case that motivated the write-up, since it is the ordinary one rather than an
exotic one: a server whose tool set is stable for years. A four-year-old record over an
unchanged catalog passes every step of §5 as implemented — verify_record and
check_tool_catalog both clean. check_tool_catalog catches "the server changed"; here
the server did not change. What changed is whether the key that signed the description
should still be trusted, and no step asks.
Activity
github-actions commentedon Aug 10, 2026
❔ Contributor Check: UNKNOWN
Automated check by AgenTrust Contributor Check.
feat(provenance): freshness and revocation, with the semantics sign.p…