Skip to content

fix(provenance): require cnf.jwk during verification - #258

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
altrudev:fix/provenance-required-cnf-jwk-255
Sep 2, 2026
Merged

fix(provenance): require cnf.jwk during verification#258
imran-siddique merged 2 commits into
agentrust-io:mainfrom
altrudev:fix/provenance-required-cnf-jwk-255

Conversation

@altrudev

@altrudev altrudev commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Dependency

Resolved. #252 has merged into main, and this PR has been restacked onto that merged base. The predecessor commits are no longer part of this diff.

Restacked head: 62add138cf29de78fb6d35ec383c45972f64ae0d.

Closes #255.

What

The server-provenance format marks cnf.jwk as required. After #252, verify_record() safely establishes cnf as an object, but missing, null, or empty cnf still preserves the old behavior: the embedded confirmation-key check is skipped and the record is verified only against the externally supplied trusted key.

This follow-on makes the required signed binding load-bearing:

cnf = _as_object(record.get("cnf"), "cnf")
embedded = cnf.get("jwk")
if not embedded:
    raise ProvenanceError("record carries no cnf.jwk")

The existing RFC 7638 thumbprint comparison then runs unconditionally for a present key.

Regression coverage

The #252 matrix is retained for non-object cnf values. Its missing/null/empty-object control is deliberately changed to the required-field rule, and extended with falsey embedded-JWK cases that pin the if not embedded guard:

  • truthy and falsey non-object cnf -> ProvenanceError from the object boundary;
  • missing cnf -> ProvenanceError;
  • cnf: null -> ProvenanceError;
  • cnf: {} -> ProvenanceError;
  • cnf: {"jwk": null} -> ProvenanceError;
  • cnf: {"jwk": {}} -> ProvenanceError;
  • valid matching embedded JWK -> unchanged success.

The records are signed over the exact malformed/edge bodies with the externally trusted key, so the refusal is not attributable to an unrelated bad signature.

Scope

No new trust-anchor mechanism and no unauthenticated-forgery claim. The external trusted key still gates authentication. This makes the reference verifier enforce the confirmation-key member the provenance format already marks required.

AI-assistance disclosure: ChatGPT assisted with source triage, adversarial-case design, implementation drafting, stacked-branch preparation, and diff review. altrudev reviewed the bounded claim and remains responsible for the contribution.

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

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

@lywinged lywinged 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.

Checks out on my side. 1028 passed, 1 skipped, ruff, check_dashes and mypy all clean, and the
guard is real: deleting the two lines makes all four parameters of
test_missing_or_empty_cnf_jwk_is_refused fail while the nine others pass.

Enumerating the cnf shapes on both branches, signing each body with the trusted key so the
refusal cannot be blamed on the signature:

                     #252 (b37519e)   this branch
cnf absent           verifies         refused
cnf: null            verifies         refused
cnf: {}              verifies         refused
cnf: {"jwk": null}   verifies         refused
cnf: {"other": "x"}  verifies         refused
cnf: {"jwk": {}}     verifies         refused
cnf: {"jwk": ""}     verifies         refused
cnf: {"jwk": false}  verifies         refused

Your coverage list names the first four. The other four are the same class and fall to the same
line, so the change is wider than the list claims rather than narrower.

One coverage note, from running the near-miss fix rather than the revert. Replacing the guard with

if embedded is None:
    raise ProvenanceError("record carries no cnf.jwk")

leaves the whole suite green at 1028 passed, 1 skipped, because all four parameters reduce to
embedded is None. The not is doing work nothing pins: it is what sends {"jwk": {}},
{"jwk": ""} and {"jwk": false} to no cnf.jwk rather than down into the thumbprint path. I
swept fourteen cnf.jwk values under both versions and every one is a ProvenanceError either
way, so this is which refusal rather than whether, and it is not a hole. Adding {"jwk": {}} to the
parametrize list pins the line as written, at the cost of one entry: I ran it, ruff stays clean,
the suite goes to 1029 passed, 1 skipped, and the is None version then fails on it.

I am not approving this yet, and the reason is a repository detail rather than anything about the
change. .github/workflows/require-maintainer-approval.yml hardcodes
const MAINTAINERS = ['imran-siddique', 'lywinged'] and gates merge on an APPROVED review from
one of them against the current head SHA, ignoring COMMENTED reviews. So an approval from me here
is not a second opinion, it is that gate going green. The gate is per pull request, so #252's own
check stays red either way, but merging this branch is what carries b051370 and b37519e into
main, and those two commits would arrive without an approval of their own. Your body is already
explicit that this is Draft until #252 merges, so the sequence you describe is the right one and it
is the approval order that needs to follow it.

My reading is that #252 should be reviewed and approved on its own first, then this one against its
own head. If a maintainer would rather I just approve both, I will.

Tool-assisted: the matrix, the sweep and this write-up.

Copy link
Copy Markdown
Contributor Author

Added {"jwk": {}} to the existing parametrized refusal test at a704b44, which pins the if not embedded behavior against the plausible is None near-miss. I also updated the coverage text to name that case.

Keeping this PR in Draft as planned until #252 merges; #252 now has maintainer approval on its current head.

@imran-siddique

imran-siddique commented Aug 31, 2026

Copy link
Copy Markdown
Member

Batch response for this cluster is here: agentrust-io/agent-manifest#357 (comment)

Short version: the finding class is real and welcome. Your CI had never run, held under first-time-contributor gating, until I released 36 runs across your PRs an hour ago, and five of your eight are now red. Please fix those, sequence trace-spec#258 against #252 which touch the same two files, and tell me the order you want them reviewed in.

@altrudev
altrudev force-pushed the fix/provenance-required-cnf-jwk-255 branch from a704b44 to 62add13 Compare September 1, 2026 03:26

altrudev commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

#252 has merged and this PR is now cleanly restacked onto the merged base at exact head 62add138cf29de78fb6d35ec383c45972f64ae0d.

The predecessor commits are no longer in the diff: this is 2 commits / 2 files over current main, and the implementation/test delta is unchanged from the reviewed #258 bytes. Fresh CI and CodeQL both pass on this exact head.

@altrudev
altrudev marked this pull request as ready for review September 1, 2026 03:31
@altrudev
altrudev requested a review from a team as a code owner September 1, 2026 03:31

@lywinged lywinged 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.

Approved on this head.

I checked the restack claim in a stronger form than it was made. Comparing a704b44 with
62add138cf29de78fb6d35ec383c45972f64ae0d, the same two files are touched on both heads and no
third one appears, and the final contents of each hash identically, so the change is the base
and not the bytes. The branch is two commits over the merged base where it was four, and the
two that dropped out are the #251 pair, which produced for both files exactly the blobs #252
landed on main. The diff line counts differ because of the base alone, and the test file
shows it most clearly: over the old base it was a new file at 78 lines, and over the merged
base, where #252 has already added it, the same final content reads as +4/-3.

The change is mandated rather than stylistic, which is worth stating since it is a behaviour
change to a public function, and your PR body already stands on the right ground: the server
provenance format, spec/server-provenance-v1.md, marks cnf.jwk required, and without one
the record is verified only against the externally supplied trusted key. Two further things in
that document sharpen it. §5 step 2 verifies the signature under cnf.jwk, so the caller's key
is the key step 2 names only once the comparison has established that it is, and the comparison
is exactly what if embedded: made optional. §5 step 3 then asks the verifier to establish
that cnf.jwk is the key it expects for publisher, which is unperformable on a record
carrying none. Refusing is what the format already said.

§6 of that document is the one place a reader might argue the other way, and it does not reach
this. Its leniency is about a server having no record at all, not about a record that omits a
field its own §3 marks required.

The package's other verify_record, the one in sign.py, has refused a record with no usable
cnf.jwk since #157 on 11 August. A different format, the same decision, so this brings the
two functions of that name into line rather than being half of something.

What I ran on this head: the suite is 1060 passed 1 skipped, and the boundary file is 14
passed. The counterfactual that matters is putting the previous if embedded: back under this
same test file. All five refusal cases then stop raising at all and the records verify, which
includes both falsey-jwk cases.

Your follow-up commit earns its place, which is worth saying because a one case test addition
usually does not. The near miss here is if embedded is None:, the fix a reviewer would most
plausibly reach for instead. Under it thirteen of the fourteen tests in the file still pass,
and the one that fails is {"jwk": {}}, the case that commit added. if "jwk" not in cnf: is
caught by two. So the truthiness test is doing work neither of those does, and without the
follow-up the file would have let the closest wrong fix through.

One distinction worth drawing, since it changes what the two new lines are for. Deleting only
those two lines and leaving the comparison unconditional does not reopen the hole. Those
records are still refused, because jwk_thumbprint chokes on them. Four of the five give the record's embedded key is unusable: jwk must be a JSON object, got NoneType, and {"jwk": {}}
gives the same wrapper around cannot compute a JWK thumbprint for kty None. So dropping the
conditional is what carries the refusal, and the two lines are what carry a message a caller
can act on. Both are worth having, and it is the message your tests assert on.

On regression risk, examples/ is not the place to look: every cnf block in it belongs to a
Trust Record, and this path never sees one. The tree stores no provenance-record fixtures at
all; the ones the suite exercises are built at run time. The evidence that matters is on the
producing side. sign_record is the only signing path in this module and it builds payload = {**record, "cnf": {"jwk": key_to_jwk(key)}} unconditionally, so every record the package can
emit carries cnf.jwk by construction and the new refusal cannot reject one. The example
record in §3 of the spec carries it too.

The branch merges onto main at ab3f06d with no conflict.

@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.

Merging. Two things worth naming, one about the change and one about how it arrived.

The change closes the half #252 left open, and the gap was the interesting kind. After #252, verify_record() established cnf as an object safely, so malformed values were refused. But a missing, null or empty cnf still skipped the embedded confirmation-key check entirely and verified the record against nothing but the externally supplied trusted key. The format marks cnf.jwk required, so a required signed binding was optional in practice, and the absence looked like success rather than like a skipped check. That is the same shape as several other things this codebase has been fixing all week: not-checked reported as checked.

Preserving the RFC 7638 comment through the restack matters more than it looks. "Compared by thumbprint, not dict equality, because kid, use and alg carry no key material" is the sort of context that explains a past bug and gets silently dropped when a diff is rewritten. It survived.

And the restack is exactly what you said you would do. You put this in Draft behind #252, said you would rebase onto the merged base and strip the predecessor before marking it ready, and the diff went from +98/-17 to +22/-19 across two files. Naming the resolved dependency and the restacked head SHA at the top of the body means I did not have to work out which commits were yours.

Six checks, only the maintainer gate red. Closes #255.

That is four of your PRs merged in order across two days with no round trips. The queue arrangement is working; keep it.

@imran-siddique
imran-siddique merged commit 6b28d4d into agentrust-io:main Sep 2, 2026
6 of 7 checks passed
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.

provenance.verify_record accepts a signed record with no required cnf.jwk

3 participants