Skip to content

fix(catalog): make the approval signing input the JCS it claims to be (#517) - #532

Merged
imran-siddique merged 1 commit into
agentrust-io:mainfrom
zohebk8s:fix/catalog-approval-jcs-canonicalization
Aug 20, 2026
Merged

fix(catalog): make the approval signing input the JCS it claims to be (#517)#532
imran-siddique merged 1 commit into
agentrust-io:mainfrom
zohebk8s:fix/catalog-approval-jcs-canonicalization

Conversation

@zohebk8s

@zohebk8s zohebk8s commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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_json is documented as "the RFC 8785-compatible JSON form used by cMCP records" and serialized with ensure_ascii=True, which is the one thing JCS does not do. The standard emits UTF-8 and escapes only what ECMAScript JSON.stringify escapes:

cmcp  : b'{"principal_id":"jos\u00e9","role":"s\u00e9curit\u00e9"}'
jcs   : b'{"principal_id":"jos\xc3\xa9","role":"s\xc3\xa9curit\xc3\xa9"}'

Any record carrying a non-ASCII principal_id, issuer, role, catalog_id, or policy_id is 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_keys is 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 with 0xD800 and sorts below a BMP character above 0xE000.

#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:

  • 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 previously escaped 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.

b'{"principal_id":"jos\xc3\xa9","role":"s\xc3\xa9curit\xc3\xa9"}'   non-ASCII stays UTF-8
b'{"\xf0\x90\x80\x80":2,"\xef\xbf\xbf":1}'                          U+10000 sorts below U+FFFF
rejected: canonical JSON does not accept floating point numbers
rejected: integer is outside the range RFC 8785 serializes exactly
rejected: canonical JSON object keys must be strings
rejected: canonical JSON cannot encode an unpaired surrogate

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 rewrites test_catalog_approval.py substantially.

tests/unit/test_catalog_canonical_json.py   13 passed
with the existing approval tests            17 passed
ruff                                         All checks passed
mypy --strict                                no issues, 1 source file

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 after PROFILE, _MAX_EXACT_INT here and _B64URL there. Keep both.
  • tests/unit/test_catalog_canonical_json.py, three lines. The end to end non-ASCII test calls verify_catalog_change, whose signature fix(catalog): pin the approval policy verifier-side and bind the chain fields (#517) #531 changes: it needs expected_policy_hash and expected_catalog_id, and compute_policy_hash in place of digest_json for 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 main goes red.

Not covered

audit/trace_claim.py, cmcp_verify/embodied_action.py, and catalog/loader.py each carry their own copy of the same sort_keys and separators idiom 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.

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>
@zohebk8s
zohebk8s requested a review from a team as a code owner August 19, 2026 18:03
@github-actions

github-actions Bot commented Aug 19, 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 19, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@imran-siddique
imran-siddique merged commit f59d958 into agentrust-io:main Aug 20, 2026
13 checks passed
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>
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.

4 participants