Repository navigation
Description
Summary
The bytes cMCP signs and hashes are not RFC 8785. The claim signing input, the claim digest used by the kill-switch refusal receipt and the prev_claim_hash link, the catalog hash and the per-tool definition hash are all produced by json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=True). For ASCII-only content with ordinary numbers that is byte-identical to JCS, which is why it has gone unnoticed. Outside that subset it is not:
ensure_ascii=Trueescapes every non-ASCII character as\uXXXX. JCS emits the UTF-8 characters.- Python renders
1e-07and1e+21where JCS renders1e-7and100000000000000000000.
A verifier that canonicalizes with RFC 8785, which is what docs/spec/attestation.md says canonical_json is and what trace-spec's own verifier uses, therefore cannot verify a cMCP claim signature, or recompute a cMCP catalog or definition hash, as soon as any signed string contains a non-ASCII character. cMCP's own verifier shares the same serializer, so cMCP-to-cMCP verification still passes, which hides the divergence.
The same class of defect was fixed for the approval signing input (#532), the embodied-action evidence (#591) and the policy bundle hash (#612). These sites are what remains. I searched cmcp issues and PRs (open and closed) for 8785, canonical, ensure_ascii and JCS before filing; none covers them. Pinned at cmcp@5c7ef0a.
Where
| Site | Code | What the docs say |
|---|---|---|
| Claim signature | src/cmcp_runtime/audit/trace_claim.py:348-354 canonical_json (docstring says "sorted keys, no whitespace, UTF-8", then passes ensure_ascii=True) used by sign_trace_claim |
docs/SPEC.md:184, docs/quickstart.md:453: signature over "canonical JSON"; docs/spec/attestation.md:445: "canonical_json uses RFC 8785" |
| Verifier mirror | src/cmcp_verify/verify.py:181-184 _canonical_json |
same |
| Claim digest in refusal receipts | src/cmcp_runtime/session/manager.py:554 |
same |
prev_claim_hash |
src/cmcp_runtime/session/manager.py:507-511 (inline json.dumps(..., ensure_ascii=True)) |
trace_claim.py:310: "sha256 of previous claim's canonical JSON" |
| Catalog hash | src/cmcp_runtime/catalog/loader.py:199 _catalog_hash |
docs/spec/attestation.md §5.2: catalog_hash = SHA-256(canonical_json(sorted_entries)) |
| Definition hash | src/cmcp_runtime/catalog/loader.py:137, :166 |
docs/spec/attestation.md §5.1: definition_hash = SHA-256(canonical_json(approved_definition)) |
Non-ASCII reaches these inputs without any guard: loader.py only requires a catalog tool_name to equal its own .lower(), and schemas/catalog-entry.schema.json puts no pattern on it. tools_invoked, the transcript entries and catalog_exceptions in the signed claim carry that name verbatim, and a tool description feeds the definition hash. kill_switch.deny_rate_threshold is a float in the signed body, so a small threshold hits the exponent form.
Reproduction
From a cmcp checkout at 5c7ef0a (PYTHONPATH=src, with rfc8785 and cryptography installed):
python - <<'PY'
import base64
import rfc8785
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cmcp_runtime.audit.keys import SigningKey
from cmcp_runtime.audit.trace_claim import (
AttestationReportInfo, CallGraphSummary, CallSummary, KillSwitchState, PolicyBundleInfo,
ToolCatalogInfo, canonical_json, generate_trace_claim,
)
from cmcp_verify.verify import _verify_signature
def claim_for(tool, threshold=0.5):
c = generate_trace_claim(
session_id="s1", signing_key=SigningKey(b"\x01" * 32),
attestation_report=AttestationReportInfo(provider="software-only", measurement="sha256:" + "0" * 64,
report_data="", attestation_generated_at="2026-11-04T00:00:00Z", attestation_validity_seconds=300),
policy_bundle=PolicyBundleInfo(hash="sha256:" + "a" * 64, enforcement_mode="enforcing", policy_version="1"),
tool_catalog=ToolCatalogInfo(hash="sha256:" + "b" * 64),
call_summary=CallSummary(tool_calls_total=1, tool_calls_allowed=1, tool_calls_denied=0, tool_calls_faulted=0,
tools_invoked=[tool], session_max_sensitivity="internal",
call_graph_summary=CallGraphSummary(compliance_domains_touched=[], cross_boundary_events=[])),
audit_chain_root="sha256:" + "c" * 64, audit_chain_tip="sha256:" + "d" * 64, audit_chain_length=1,
kill_switch=KillSwitchState(enabled=True, window_seconds=60, deny_rate_threshold=threshold, min_calls=5),
)
return c.model_dump(exclude_none=True)
def independent_jcs_verifies(claim):
pub = Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(claim["trace"]["cnf"]["jwk"]["x"] + "="))
sig = base64.urlsafe_b64decode(claim["signature"] + "=" * (-len(claim["signature"]) % 4))
body = {k: v for k, v in claim.items() if k != "signature"}
try:
pub.verify(sig, rfc8785.dumps(body))
return True
except Exception:
return False
for label, tool, thr in [("ASCII control ", "search_repos", 0.5),
("non-ASCII tool name ", "caf\u00e9_lookup", 0.5),
("deny_rate_threshold=1e-07", "search_repos", 1e-07)]:
c = claim_for(tool, thr)
print(label, "| cmcp verifier:", _verify_signature(c)[0], "| RFC 8785 verifier:", independent_jcs_verifies(c),
"| bytes equal:", canonical_json(c) == rfc8785.dumps({k: v for k, v in c.items() if k != "signature"}))
# the same serializer feeds the catalog digests that attestation.md section 5 defines with canonical_json (RFC 8785)
import hashlib
from cmcp_runtime.catalog.loader import _catalog_hash, _compute_definition_hash
d = {"name": "lookup", "description": "Look up a caf\u00e9 menu", "input_schema": {"type": "object"}}
e = [{"tool_name": "caf\u00e9_lookup", "server": "s"}]
jcs = lambda v: "sha256:" + hashlib.sha256(rfc8785.dumps(v)).hexdigest()
print("definition_hash matches JCS:", _compute_definition_hash(d) == jcs(d))
print("catalog_hash matches JCS:", _catalog_hash(e) == jcs(e))
PY
Output:
ASCII control | cmcp verifier: True | RFC 8785 verifier: True | bytes equal: True
non-ASCII tool name | cmcp verifier: True | RFC 8785 verifier: False | bytes equal: False
deny_rate_threshold=1e-07 | cmcp verifier: True | RFC 8785 verifier: False | bytes equal: False
definition_hash matches JCS: False
catalog_hash matches JCS: False
The first row is the control: with ASCII content and an ordinary float the two serializers agree byte for byte, so both verifiers accept the claim. The second and third rows are the same signed claim shape with one character or one number changed. cMCP's verifier still accepts it, and an independent Ed25519 verification over rfc8785.dumps(body) of the identical claim does not. The last two lines show the catalog digests diverging from a JCS recomputation for a lowercase non-ASCII tool name and a non-ASCII description.
Why it matters
- Cross-stack verification. The trace-spec reference verifier canonicalizes with RFC 8785 (
rfc8785), and independent implementations such as the Go verifier tracked intrace-spec#444/#448are expected to follow the same rule. A gateway that happens to expose acafé_lookuptool produces claims they reject as a signature failure, with nothing in the claim to say why. - Silent divergence is the worst failure mode. The divergence appears only for some deployments, and only when someone outside cMCP checks. It also means
tool_catalog.hashin a claim cannot be reproduced by a relying party that follows the published algorithm, which is the property the field exists for. - The claim chain is affected too.
prev_claim_hashand the refusal receipt'sclaim_digesthash the same non-JCS bytes, so any external party linking claims or receipts to claims computes different digests than the gateway.
Proposed fix (not a PR yet; the queue is being merged)
- Replace the five
ensure_ascii=Truesites above with therfc8785serializer already used inagent_manifest.py,disclosure.py,manifest_catalog.pyandcatalog/approval.py. One shared helper, so the claim signer,cmcp_verifyand the manager cannot drift again. - No change for existing ASCII-only claims and catalogs: the control row shows the bytes are identical, so already-signed claims, pinned catalog hashes and
definition_hashvalues keep verifying. Only content that was already unverifiable by a JCS verifier changes. - Persisted audit-chain hashes (
audit/chain.py:98, stored inaudit_entries) are a separate decision, because they are recomputed from stored rows on restart. I would leave that serializer alone in this change and call it out in the changelog rather than invalidate stored chains; the claim, catalog and definition hashes are the externally recomputed ones. - Regression vectors: the three non-ASCII rows above, with a second tool whose name sorts differently under UTF-16 and code-point order, so the key-ordering difference is also exercised (execution-action-binding v1 vectors cannot discriminate RFC 8785 ordering from a code-point sort #630 made the same point for the execution binding vectors).
Activity
github-actions commentedon Nov 4, 2026
🔴 Contributor Check: HIGH
Automated check by AgenTrust Contributor Check.