fix(catalog): pin the approval policy verifier-side and bind the chain fields (#517) - #531
Conversation
|
🟡 Contributor Check: MEDIUM
Automated check by AgenTrust Contributor Check. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
#532 has landed, and it touches the same catalog approval signing path this changes, so this now conflicts in Could you rebase on main? I did not resolve it here: after #532 the two fixes overlap, and picking the resolution inside the signing input is the kind of thing that quietly changes what gets signed. You know which parts of this are still needed once #532 is in; I would only be guessing. Everything else on the PR is green. |
…n fields verify_catalog_change read threshold, distinct_principals, and distinct_roles out of the record under verification, and checked policy_hash for digest shape only. A single trusted reviewer key could therefore issue a record declaring a threshold of one and have it verify, so the M-of-N property was unenforced. expected_policy_hash and expected_catalog_id are now required keyword arguments. The record's policy_hash must cover its own policy body and must equal the policy the verifier was configured with. compute_policy_hash defines that digest so producers and verifiers agree on it. previous_catalog_hash, sequence, and catalog_id were format checked and then unused. catalog_id is now always bound. expected_sequence and expected_previous_catalog_hash join expected_previous_record_hash as optional checkpoints, since those must come from an external pin. Also in this pass: * reject repeated principals and roles rather than counting distinct values, which admitted alice, bob, alice at a threshold of two * validate string and integer field types, so a non-hashable role raises CatalogApprovalError instead of escaping as TypeError * reject boolean timestamps, which the JSON Schema already rejected * validate the signature alphabet and decoded length, and move the decode out of the try block where its errors were masked as "signature is invalid" * drop the post-loop threshold checks, now unreachable Not addressed here: approvals are still judged against wall clock, so a record stops verifying once its approvals expire, and the JSON Schema is still not loaded by the verifier or shipped in the wheel. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
The four tests that landed with agentrust-io#519 pass whether or not the policy, identity, and validity checks exist. Deleting the threshold count, the distinct-role guard, the role match, the principal and issuer match, or the interval order check leaves the suite green, so the module's M-of-N claim rested on code review alone. Added: * threshold shortfall, one approval against a 2-of-N policy * repeated role under a distinct-role policy, with distinct principals so the principal guard cannot mask it * principal, issuer, and role mismatches against the trusted key * validity boundaries, approved_at inclusive, expires_at exclusive, and an inverted interval * a genesis record at sequence 1, which fixes the all-zero previous_record_hash as the convention until the schema says otherwise * a schema and verifier agreement test over eleven malformed records, asserting that whatever the shipped schema rejects the verifier rejects too Each new test was checked by removing the guard it covers and confirming the test fails. The agreement test is the one that would have caught the bool timestamp divergence. One case is marked xfail strict: the schema sets a minimum of zero on approved_at and expires_at, the verifier does not, so a record with a negative approval timestamp verifies. That is left failing on purpose, since fixing it belongs with the decision on whether the verifier loads the schema at all. The schema is read from the repository at tests/../schemas, following test_trace_claim.py, because it is still absent from the wheel. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Codecov flagged one line of the diff: the policy distinctness flags guard, which nothing exercised. Three more lines in the module were uncovered before this PR and are cheap to reach from the same fixture, so they go with it. * a policy flag set to a truthy string rather than a boolean * an unknown profile * a digest of the wrong shape as well as the wrong alphabet * an approval carrying a stray member * a one character signature, which is the only input that reaches the base64 decode error path, since anything with a bad alphabet is rejected before it and anything longer decodes and fails on length The module is now at 100 percent line coverage, 28 tests and the deliberate xfail. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
agentrust-io#532 landed first, and its end to end test calls verify_catalog_change, which this branch gives two required keyword arguments. Left alone the test fails with a TypeError on main once this merges, so the fix belongs here rather than in a follow-up. The record it builds is unchanged apart from computing policy_hash with compute_policy_hash, which is what the verifier now requires the field to cover. The point of the test is unaffected: a non-ASCII reviewer identity still signs and verifies end to end. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
09a6d91 to
0b67e01
Compare
|
Rebased on main. Three interactions with #532:
The canonicalization is untouched: After the rebase: 41 passed and 1 xfailed across both catalog test files, |
The M-of-N property still had a hole that the distinctness rules do not close, because nothing bound approvals to distinct keys. Both of these verified before this change: * threshold 3 with distinct_principals false, one key signing the identical approval three times, accepted with valid_approvals 3 * threshold 2 with distinct_roles true, one key signing twice as "security" and "owner", accepted, because a TrustedReviewer with role None lets the record assert whatever role it likes The first is arguably what the policy asked for, but a repeated signature is one approval presented three times rather than three approvals. The second is not what the policy asked for at all: it demands two roles and got one key. A key now counts once per record, checked after the principal and role rules so their errors keep reporting the more specific cause. Verified by removing the guard: 2 of the 31 tests fail. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
|
One more commit, and it changes behaviour rather than just moving it, so flagging it rather than letting it ride in after your review. Going back over the M-of-N property I found that the distinctness rules do not close it, because nothing bound approvals to distinct keys. Both of these verified on the branch as you reviewed it, and on main today: The first is defensible as what that policy asked for, though a repeated signature is one approval presented three times rather than three approvals. The second is not: the policy demands two roles and one key satisfies it, because a A reviewer key now counts once per record whatever the policy says. The check runs after the principal and role rules so those keep reporting the more specific cause, and a legitimate 2-of-2 is unaffected. Two tests, one per case, and a sentence in the spec doc stating the rule. This predates the PR and I could have filed it separately. It is here because M-of-N being enforceable is what the PR claims, and it was not while a single key could satisfy any threshold. Happy to split it out if you would rather review it on its own. Branch now: 43 passed and 1 xfailed across both catalog test files, |
Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
imran-siddique
left a comment
There was a problem hiding this comment.
Reviewed the rebased branch. This closes a real authorization bypass, not a hardening nit.
The bypass as it stood: verify_catalog_change read threshold, distinct_principals and distinct_roles out of the record it was verifying, and checked policy_hash for digest shape only. So one trusted reviewer key, threshold: 1, arbitrary bytes in policy_hash, sign once, and the record verifies. M-of-N was advertised and never enforced. Pinning expected_policy_hash and expected_catalog_id verifier-side is the right fix, and compute_policy_hash gives producers and verifiers a shared definition the spec doc had left undefined.
The key-reuse commit you flagged separately is the one I would have asked for if it were not already here. The distinctness rules did not close M-of-N, because nothing bound approvals to distinct keys: threshold 2, distinct_roles true was satisfied by a single key signing twice as "security" and "owner", since a TrustedReviewer with role=None lets the record assert whatever role string it likes. Counting a key once, and placing that check after the principal and role rules so the specific errors still win, is correct.
What I verified rather than took on trust:
- The guard-deletion table. Each row is a guard removed and the suite failing, which is the evidence that the four tests merged with #519 passed with or without the checks existing. That is how this shipped in the first place, and it is the reason the new tests are worth more than their line count.
- The breaking signature change is free today.
verify_catalog_changehas no callers on main outside its own definition and the two test files, at 3332fef. Two required keyword arguments cannot be defaulted without falling back to the record's own claim, so defaulting them would reopen the hole. Taking the break now is right. - The
test_catalog_canonical_json.pyend-to-end test being carried onto the new signature in this branch. Leaving it would have broken main on merge.
Non-blocking, and already tracked in #533: the verification-clock question, the schema not being loaded or shipped in the wheel, and the negative-timestamp divergence held as a strict xfail. Holding that as xfail rather than quietly widening the verifier is the right call, and it will start failing the moment the schema decision lands.
Merging. #517 stays open for the wiring phases.
…ecords (#546) schemas/catalog-approval.schema.json shipped in the repository and nowhere else. verify_catalog_change reimplemented structural validation by hand, the two drifted, and the file was absent from the wheel force-include, so an installed distribution did not carry it at all. #531 could only document one divergence, a negative approved_at that the schema rejects and the verifier accepted, as a strict xfail. The record is now validated against the schema before any other check, and verification refuses outright when the schema is missing from the installation, following loader.py rather than falling back to hand-written checks. Duplicated checks are deleted rather than left unreachable: field presence and unknown members on the record, the policy and each approval, digest shapes, integer and boolean types, string emptiness, and the signature alphabet. _require_str and _require_int go with them. What stays is what JSON Schema cannot express: * the runtime catalog hash binding and the caller's chain checkpoints * the policy pin, including that policy_hash covers its own body * reviewer identity, role, revocation and key reuse * the validity interval ordering * the signatures The xfail is now a plain assertion, since the case fails closed. previous_record_hash on the first record in a chain is the all-zero digest, sha256: followed by 64 zeros, written into the schema and the spec doc. It validated against the pattern before and meant nothing. Packaging: the schema is force-included beside catalog-entry.schema.json, and verify_python_distribution.py checks both resolve to a file inside the installed distribution, so a wheel that drops one fails the release smoke test rather than a verifier at runtime. A unit test asserts the force-include mapping, since that line is the only thing putting these files next to the code. loader.py's _CATALOG_ENTRY_SCHEMA_PATH is now CATALOG_ENTRY_SCHEMA_PATH, since the distribution check imports it. Verified by mutation: dropping the schema call fails 7 tests, accepting a missing schema fails 1, dropping the 64-byte signature check fails 1. Coverage of approval.py is 149 statements with none missing. Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> Co-authored-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Follow-up to #519, which implemented #517. Does not close #517; see Not covered.
Why
The module advertises M-of-N approval and does not enforce it.
verify_catalog_changereadsthreshold,distinct_principals, anddistinct_rolesout of the record it is verifying, and checkspolicy_hashfor digest shape only: it is never recomputed over the policy body and never compared to anything the verifier was configured with.A single trusted reviewer key is therefore sufficient. Set
threshold: 1,distinct_principals: false, put arbitrary bytes inpolicy_hash, sign once:This is the failure the spec doc already forbids for keys, "no record-embedded key can bootstrap trust", applied to the policy instead. M is only meaningful when it comes from verifier-side configuration.
Three smaller defects sit behind the same seam:
catalog_id,sequence, andprevious_catalog_hashwere format checked and then never used, so a record for a different catalog verified against this one.alice, bob, alicesatisfied a threshold of two under a distinct-principal policy.roleescaped asTypeErrorrather thanCatalogApprovalError, and boolean timestamps were accepted where the shipped JSON Schema rejects them.What
The policy is pinned verifier-side.
expected_policy_hashandexpected_catalog_idare new required keyword arguments. A record'spolicy_hashmust cover its own policy body, and must equal the policy the verifier was configured with.compute_policy_hashdefines that digest, over the policy object withpolicy_hashremoved, so producers and verifiers agree on it. The spec doc left this undefined.The chain fields are bound.
catalog_idis always checked.expected_sequenceandexpected_previous_catalog_hashjoin the existingexpected_previous_record_hashas optional checkpoints, optional because #517 is explicit that they must come from an external pin or transparency receipt, which the record cannot supply.Repeats are rejected as each approval is read, rather than counted after the loop, which also makes the post-loop threshold checks unreachable; they are removed.
A reviewer key counts once, whatever the policy says, which is the part the distinctness rules did not cover. Nothing bound approvals to distinct keys, so both of these verified:
The first is arguably what that policy asked for, though a repeated signature is one approval presented three times rather than three approvals. The second is not what the policy asked for at all: it demands two roles and is satisfied by one key, because a
TrustedReviewerwithrole=Nonelets the record assert whatever role string it likes. The check sits after the principal and role rules so their more specific errors still win. This predates this PR and is onmainin the same form, but M-of-N is what this PR claims to make enforceable, so it belongs here.Types are validated. String and integer fields are checked before use, booleans are rejected where an integer is required, and the signature is checked against the base64url alphabet and for a 64 byte decode. The decode moves out of the
tryblock where its errors were being reported as "signature is invalid".Tests
The four tests merged with #519 pass whether or not the policy, identity, and validity checks exist, which is how this shipped. The suite is now 31 tests, and every guard was checked by deleting it and confirming a test fails:
Each row is one guard deleted from
approval.py, the suite rerun, and the source restored. The xfailed case below stays xfailed throughout and is never one of the failures.Added beyond the fixes above: reviewer identity mismatches, validity interval boundaries, a genesis record at
sequence: 1, and an agreement test over eleven malformed records asserting that whatever the shipped schema rejects the verifier rejects too. That last one is what would have caught the boolean timestamp divergence.The last commit closes the remaining error branches, including the four that were uncovered before this PR. One of them is only reachable with a signature whose length is 1 mod 4, since a bad alphabet is rejected before the decode and anything longer decodes and then fails the length check.
approval.pyis at 100 percent line coverage.The xfail is deliberate and strict. The schema sets
minimum: 0onapproved_atandexpires_at; the verifier applies no lower bound, so a record with a negative approval timestamp verifies. Fixing it belongs with the decision on whether the verifier loads the schema at all, so the test documents the divergence rather than hiding it, and will start failing the moment that lands.Decisions worth reviewing
The signature change is breaking, and deliberately so. Two new required keyword arguments cannot be defaulted without leaving the hole open: any default for
expected_policy_hashmeans falling back to the record's own claim. Nothing in the tree callsverify_catalog_changeyet, so the cost is zero today and rises with every week it waits.compute_policy_hashexcludespolicy_hashfrom its own input. The alternative is hashing the policy as configured, including the field, which is self-referential. Either convention works as long as it is written down; this one is now in the spec doc.Not covered
schemas/catalog-approval.schema.jsonis still not loaded by the verifier and still not in[tool.hatch.build.targets.wheel.force-include], so it does not ship in the wheel. Whether the verifier should load it, and what a genesis record puts inprevious_record_hash, are the same kind of decision. Also decide: approval validity clock and schema wiring for catalog approval provenance #533.canonical_jsonis not the RFC 8785 it claims to be. That was fix(catalog): make the approval signing input the JCS it claims to be (#517) #532, kept separate as an interop question rather than an authorization one, and it has landed. This branch is rebased on it: the two constants sit side by side, both CHANGELOG entries are kept, and the last commit carries fix(catalog): make the approval signing input the JCS it claims to be (#517) #532's end to end test onto the verifier signature this PR changes, since leaving it would breakmainthe moment this merges.