Skip to content

TRACE claim signing input, claim digests, catalog hash and definition hash are not RFC 8785: json.dumps(ensure_ascii=True) makes non-ASCII or exponent-form content unverifiable by a JCS verifier #837

Open

Description

@drk-ram

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=True escapes every non-ASCII character as \uXXXX. JCS emits the UTF-8 characters.
  • Python renders 1e-07 and 1e+21 where JCS renders 1e-7 and 100000000000000000000.

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

  1. Cross-stack verification. The trace-spec reference verifier canonicalizes with RFC 8785 (rfc8785), and independent implementations such as the Go verifier tracked in trace-spec#444 / #448 are expected to follow the same rule. A gateway that happens to expose a café_lookup tool produces claims they reject as a signature failure, with nothing in the claim to say why.
  2. 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.hash in a claim cannot be reproduced by a relying party that follows the published algorithm, which is the property the field exists for.
  3. The claim chain is affected too. prev_claim_hash and the refusal receipt's claim_digest hash 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=True sites above with the rfc8785 serializer already used in agent_manifest.py, disclosure.py, manifest_catalog.py and catalog/approval.py. One shared helper, so the claim signer, cmcp_verify and 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_hash values keep verifying. Only content that was already unverifiable by a JCS verifier changes.
  • Persisted audit-chain hashes (audit/chain.py:98, stored in audit_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

  1. github-actions commented on Nov 4, 2026

    @github-actions
    Contributor

    🔴 Contributor Check: HIGH

    Check Result
    Profile HIGH
    Credential LOW
    Overall HIGH

    Automated check by AgenTrust Contributor Check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-review:HIGHContributor check flagged HIGH risk

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions