fix(catalog): make the approval signing input the JCS it claims to be (#517) - #532
Merged
imran-siddique merged 1 commit intoAug 20, 2026
Conversation
canonical_json is documented as RFC 8785 compatible and serialized with
ensure_ascii=True, which is the one thing JCS does not do. Any record carrying a
non-ASCII principal_id, issuer, role, catalog_id, or policy_id was signed over
different bytes than a conforming producer signs, so a record produced by any
other implementation failed with an invalid-signature error that points nowhere
near the encoding:
cmcp : b'{"principal_id":"jos\u00e9"}'
jcs : b'{"principal_id":"jos\xc3\xa9"}'
sort_keys was the second divergence, ordering members by code point where JCS
orders by UTF-16 code unit. The two disagree for any key outside the BMP, since
a surrogate pair leads with 0xD800 and sorts below a BMP character above 0xE000.
Members are now ordered on their UTF-16BE bytes, which is the same comparison
since every code unit occupies two bytes, and the output is UTF-8.
Values JCS cannot pin down are refused rather than serialized into a signing
input two implementations would read differently:
* floating point numbers, whose JCS serialization is the ECMAScript number
algorithm and is not implemented here
* integers beyond 2**53 - 1, which JCS treats as IEEE 754 doubles
* non-string object keys, which have no canonical order
* unpaired surrogates, which no longer escape as UnicodeEncodeError
Each raises CatalogApprovalError, matching how the rest of the module reports
malformed input. Approval records carry none of these, so refusing them only
closes a door.
ASCII-only records serialize to the same bytes as before, pinned by test, so
nothing that verifies today stops verifying. agentrust-io#517 asks for JCS reuse precisely
so a record can cross implementations, and nothing consumes these records at
runtime yet, so the encoding is still free to correct.
Verified by reverting canonical_json alone: 11 of the 13 new tests fail. The two
that survive are the ASCII byte-stability pin and the largest exact integer,
both of which are meant to be invariant.
Not addressed: audit/trace_claim.py, cmcp_verify/embodied_action.py, and
catalog/loader.py each carry their own copy of the sort_keys idiom with the same
two divergences. Only this one is described as RFC 8785 in its docstring, and
only this one signs reviewer identities, so the others are left alone here.
Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
Contributor
|
🟡 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! |
zohebk8s
pushed a commit
to zohebk8s/cmcp
that referenced
this pull request
Aug 20, 2026
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>
imran-siddique
pushed a commit
that referenced
this pull request
Aug 21, 2026
…n fields (#517) (#531) * fix(catalog): pin the approval policy verifier-side and bind the chain 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> * test(catalog): cover the approval checks the suite never asserted The four tests that landed with #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> * docs(changelog): record the catalog approval policy pinning fix Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> * test(catalog): point the xfail at #533 Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> * test(catalog): cover the three branches the suite still missed 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> * test(catalog): carry the JCS round trip onto the new verifier signature #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> * fix(catalog): count a reviewer key once, whatever the policy says 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> * docs(changelog): record the reviewer key reuse rule Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> * docs(spec): say that a key identifier must name one key Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> --------- Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com> Co-authored-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Contributes to #517. Independent of #531 and reviewable on its own; see Interaction with #531 for what landing both costs. The two items from that review that need a maintainer decision rather than a fix are #533; neither touches canonicalization, so nothing here waits on them.
Why
canonical_jsonis documented as "the RFC 8785-compatible JSON form used by cMCP records" and serialized withensure_ascii=True, which is the one thing JCS does not do. The standard emits UTF-8 and escapes only what ECMAScriptJSON.stringifyescapes:Any record carrying a non-ASCII
principal_id,issuer,role,catalog_id, orpolicy_idis therefore signed over different bytes than a conforming producer signs. A record produced by any other implementation fails here with "approval signature is invalid", which points nowhere near the encoding, and a reviewer whose name is not ASCII cannot approve a catalog change at all.sort_keysis the second divergence. It orders members by code point where JCS orders by UTF-16 code unit. The two disagree for any key outside the BMP, since a surrogate pair leads with0xD800and sorts below a BMP character above0xE000.#517 asks for RFC 8785 reuse specifically so these records can cross implementations, so this is a conformance defect rather than a preference.
What
Members are ordered on their UTF-16BE bytes, which is the same comparison JCS specifies since every code unit occupies two bytes, and the output is UTF-8.
Values JCS cannot pin down are refused rather than serialized into a signing input two implementations would read differently:
2**53 - 1, which JCS treats as IEEE 754 doubles;UnicodeEncodeError.Each raises
CatalogApprovalError, matching how the rest of the module reports malformed input. Approval records carry none of these, so refusing them only closes a door.The spec doc now states the canonicalization rules and what is refused, which it did not before.
Compatibility
ASCII-only records serialize to exactly the same bytes as before. A pinned digest and a byte-for-byte assertion cover it, so nothing that verifies today stops verifying. Nothing in the tree consumes these records at runtime yet, so there is no deployed producer to migrate; the encoding will stop being free to correct as soon as there is.
Tests
13 new tests in
tests/unit/test_catalog_canonical_json.py, kept in a separate file so they do not collide with #531, which rewritestest_catalog_approval.pysubstantially.Verified by restoring the old one-line implementation: 11 of the 13 fail. The two that survive are the ASCII byte-stability pin and the largest exact integer, both of which are meant to be invariant across this change.
Interaction with #531
Both branches are cut from
main, neither needs the other, and either can be reviewed first. They do overlap textually. I applied this branch on top of #531 rather than guess, so the cost of landing both is measured:CHANGELOG.md, conflict. Both add an entry at the same anchor, the top of Unreleased/Fixed. Keep both, in whichever order reads better.src/cmcp_runtime/catalog/approval.py, conflict. Both add a module constant directly afterPROFILE,_MAX_EXACT_INThere and_B64URLthere. Keep both.tests/unit/test_catalog_canonical_json.py, three lines. The end to end non-ASCII test callsverify_catalog_change, whose signature fix(catalog): pin the approval policy verifier-side and bind the chain fields (#517) #531 changes: it needsexpected_policy_hashandexpected_catalog_id, andcompute_policy_hashin place ofdigest_jsonfor the policy.The spec doc merges cleanly. Resolved as above, with both branches applied: 39 passed, 1 xfailed, ruff clean.
Merging #531 first is the tidier order. The third item is a test file this PR owns but #531 breaks, so in the other order #531 has to edit a file it does not otherwise touch, or
maingoes red.Not covered
audit/trace_claim.py,cmcp_verify/embodied_action.py, andcatalog/loader.pyeach carry their own copy of the samesort_keysandseparatorsidiom with the same two divergences. Only this one claims RFC 8785 in its docstring, and only this one signs reviewer identities, so the others are left alone here. Whether they collapse into a shared helper is a separate question, and the answer decides whether their wire formats can move at all.