Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions src/trace_tests/modules/tr_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ def _canonical_json(d: dict[str, Any]) -> bytes:
return rfc8785.dumps(d)


def _canonical_body(d: dict[str, Any]) -> tuple[bytes | None, str]:
"""Canonical bytes for *d*, or ``None`` and the reason there are none.

Two contracts meet here and they disagree. ``_canonical_json`` raises by design: it
is the RFC 8785 serializer, and a value JCS has no form for has no canonical bytes,
so there is nothing for it to return. ``runner.run`` calls every module with no
``try``, so a module that lets that reach the caller ends the run and the record is
neither passed nor failed.

This is the boundary between them. ``_canonical_json`` keeps raising, because the
canonicalization tests compare its bytes directly and a wrapper that swallowed the
error would hide a real serializer defect. Callers that must return a verdict use
this instead.

``IntegerDomainError`` and ``FloatDomainError`` both derive from
``CanonicalizationError``, so one clause covers all three: an integer outside the JCS
safe range, a non-finite float, and a string carrying a lone surrogate. All three are
ordinary JSON. ``json.loads`` accepts them, ``load_record`` has no reason to refuse
them, and before this they reached the CLI as a traceback.
"""
try:
return _canonical_json(d), ""
except rfc8785.CanonicalizationError as exc:
return None, str(exc)


def _verify_ed25519(pub_x: str, sig_b64: str, body: bytes) -> tuple[bool, str]:
"""Verify *sig_b64* over *body*, returning ``(ok, message)``.

Expand Down Expand Up @@ -126,7 +152,18 @@ def check_cmcp_runtime(record: dict[str, Any]) -> list[Finding]:
findings.append(Finding("TR-SIG-002", Status.FAIL, "TR-SIG-002: cnf.jwk.x is missing"))
return findings

body = _canonical_json({k: v for k, v in record.items() if k != "signature"})
body, why = _canonical_body({k: v for k, v in record.items() if k != "signature"})
if body is None:
# Not "verification failed". No signature over this claim could verify, because
# the bytes a signature is taken over do not exist. A consumer acting on the
# other reading would go looking for a key problem.
findings.append(Finding(
"TR-SIG-001", Status.FAIL,
f"TR-SIG-001: claim has no RFC 8785 canonical form, so there are no bytes "
f"to check the signature against ({why})",
))
return findings

ok, msg = _verify_ed25519(x, sig, body)
status = Status.PASS if ok else Status.FAIL
findings.append(Finding("TR-SIG-001", status, msg))
Expand Down Expand Up @@ -180,10 +217,17 @@ def check(trace: dict[str, Any], record: dict[str, Any], fmt: str, level: int =

sig = trace.get("signature", "")
if sig and kty == "OKP" and crv == _ED25519_CRV and jwk.get("x"):
body = _canonical_json({k: v for k, v in trace.items() if k != "signature"})
ok, msg = _verify_ed25519(jwk["x"], sig, body)
status = Status.PASS if ok else Status.FAIL
findings.append(Finding("TR-SIG-005", status, msg))
body, why = _canonical_body({k: v for k, v in trace.items() if k != "signature"})
if body is None:
findings.append(Finding(
"TR-SIG-005", Status.FAIL,
f"TR-SIG-005: record has no RFC 8785 canonical form, so there are no "
f"bytes to check the signature against ({why})",
))
else:
ok, msg = _verify_ed25519(jwk["x"], sig, body)
status = Status.PASS if ok else Status.FAIL
findings.append(Finding("TR-SIG-005", status, msg))
elif sig:
findings.append(Finding(
"TR-SIG-005", Status.FAIL,
Expand Down
100 changes: 100 additions & 0 deletions tests/test_canonicalization_refusals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""A record JCS has no canonical form for must be refused, not raise.

``tr_sig`` verifies over RFC 8785 canonical bytes, which is what specification §3.2.2
requires. A value RFC 8785 cannot serialize therefore has no signing input, and
``rfc8785.dumps`` says so by raising. ``runner.run`` calls every module with no ``try``,
so until this was fixed that exception left the module and ended the run: the caller got
a traceback where a verdict belongs, exiting 1, which is also what an honest FAIL exits.

Three classes of value do it, and all three are ordinary JSON that ``json.loads`` accepts
and ``load_record`` has no reason to refuse: an integer outside the JCS safe range, a
non-finite float, and a string carrying a lone surrogate. The vectors here are files on
disk read through ``load_record``, because reachability from a file rather than only from
a library caller is the whole reason this matters.

``tests/test_modules_never_raise.py`` states the contract these violate and could not see
them: all nine values its ``JUNK`` tuple carried serialize through ``rfc8785`` without
complaint, so no number of runs of it could reach a module raising while canonicalizing.
Its tuple now carries the three, which is the general guard. This file is the specific
one, and it asserts the verdict rather than only the absence of an exception, because a
module that returned nothing at all would satisfy the general guard.
"""
from __future__ import annotations

import json
import pathlib

import pytest
import rfc8785

from trace_tests.loader import load_record
from trace_tests.result import Status
from trace_tests.runner import run

VECTORS = pathlib.Path(__file__).resolve().parent / "vectors"

#: vector, the code its outcome is published under, the rfc8785 error it must provoke.
#: The code differs by call site: a cmcp claim reports its signature outcome under
#: TR-SIG-001 and a plain TRACE record under TR-SIG-005, per docs/error-codes.md.
CASES = [
("invalid_canonical_integer_out_of_range.json", "TR-SIG-001", rfc8785.IntegerDomainError),
("invalid_canonical_non_finite_float.json", "TR-SIG-001", rfc8785.FloatDomainError),
("invalid_canonical_lone_surrogate.json", "TR-SIG-001", rfc8785.CanonicalizationError),
("invalid_canonical_plain_trace.json", "TR-SIG-005", rfc8785.IntegerDomainError),
]


@pytest.mark.parametrize(("filename", "code", "error"), CASES)
def test_the_vector_still_provokes_the_error_it_was_written_for(
filename: str, code: str, error: type[Exception]
) -> None:
"""Checked before the verdict, because a vector edited into serializability would
leave every assertion below passing over a record that exercises nothing."""
record = json.loads((VECTORS / filename).read_text(encoding="utf-8"))
body = {k: v for k, v in record.items() if k != "signature"}
with pytest.raises(error):
rfc8785.dumps(body)


@pytest.mark.parametrize(("filename", "code", "error"), CASES)
def test_a_record_with_no_canonical_form_fails_rather_than_raising(
filename: str, code: str, error: type[Exception]
) -> None:
record, fmt = load_record(str(VECTORS / filename))

results = run(record, fmt, 0)

findings = [f for f in results.get("TR-SIG", []) if f.code == code]
assert findings, f"nothing published under {code}: {results.get('TR-SIG')}"
assert any(f.status is Status.FAIL for f in findings), (
f"{filename} has no canonical form, so no signature over it can verify; "
f"{code} is {[f.status for f in findings]}"
)


@pytest.mark.parametrize(("filename", "code", "error"), CASES)
def test_the_finding_names_the_cause_and_not_a_signature_mismatch(
filename: str, code: str, error: type[Exception]
) -> None:
"""Two different facts, and a consumer acting on the wrong one goes looking for a key
problem that is not there. The bytes a signature would be taken over do not exist."""
record, fmt = load_record(str(VECTORS / filename))

findings = [f for f in run(record, fmt, 0).get("TR-SIG", []) if f.code == code]
message = " ".join(f.message for f in findings)

assert "canonical form" in message, message
assert "verification failed" not in message, message


def test_the_control_still_verifies_normally() -> None:
"""Without this, a change that made TR-SIG fail on everything would pass the three
tests above while destroying the module."""
record, fmt = load_record(str(VECTORS / "valid_cmcp_runtime.json"))

findings = [f for f in run(record, fmt, 0).get("TR-SIG", []) if f.code == "TR-SIG-001"]

assert findings, "the control publishes no TR-SIG-001 at all"
assert all("canonical form" not in f.message for f in findings), (
f"the control record canonicalizes; nothing should report otherwise: {findings}"
)
12 changes: 11 additions & 1 deletion tests/test_modules_never_raise.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@
#: Values a record can carry where an object or a string is expected. `True` is here
#: because `isinstance(True, int)`; `False` and `0` because a bare truthiness test
#: reads them as absent, which is a different branch from a wrong type.
JUNK: tuple[Any, ...] = ("a-string", 123, None, [1, 2], True, False, 0, {}, "")
#:
#: The last three are a different axis and were added after the first nine reported this
#: file clean over a class they cannot represent. Every one of the nine serializes through
#: ``rfc8785`` without complaint, so no number of runs could reach a module that raises
#: while canonicalizing. These three are the ones JCS has no form for: an integer outside
#: the safe range, a non-finite float, and a lone surrogate. All three are ordinary JSON
#: that ``json.loads`` accepts and ``load_record`` has no reason to refuse.
JUNK: tuple[Any, ...] = (
"a-string", 123, None, [1, 2], True, False, 0, {}, "",
10**20, float("inf"), "\ud800",
)

TOP_LEVEL = (
"cnf", "runtime", "policy", "tool_transcript", "build_provenance",
Expand Down
70 changes: 70 additions & 0 deletions tests/vectors/invalid_canonical_integer_out_of_range.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"cmcp_version": "1.0",
"trace": {
"eat_profile": "tag:agentrust-io.com,2026:trace-v0.2",
"iat": 1748000000,
"subject": "spiffe://cmcp.gateway/session/test-session-001",
"runtime": {
"platform": "intel-tdx",
"measurement": "sha256:a3f8d2b4e1c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8",
"nonce": "dGVzdC1ub25jZQ"
},
"policy": {
"bundle_hash": "sha256:b4e1c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4",
"enforcement_mode": "enforce",
"version": "1.0.0"
},
"data_class": "confidential",
"tool_transcript": {
"hash": "sha256:c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8",
"call_count": 5
},
"cnf": {
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"kid": "cmcp-d75a9947"
}
}
},
"gateway": {
"session_id": "test-session-001",
"gateway_version": "0.1.0",
"sequence_number": 1,
"prev_claim_hash": null,
"audit_chain": {
"root": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"tip": "sha256:c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8",
"length": 5
},
"call_summary": {
"tool_calls_total": 100000000000000000000,
"tool_calls_allowed": 4,
"tool_calls_denied": 1,
"tool_calls_faulted": 0,
"tools_invoked": [
"read_file",
"write_file",
"search"
],
"session_max_sensitivity": "confidential",
"call_graph_summary": {
"compliance_domains_touched": [
"pii",
"financial"
],
"cross_boundary_events": [],
"edges_represent": "temporal-adjacency"
}
},
"catalog": {
"hash": "sha256:d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4",
"drift_detected": false
},
"attestation_generated_at": "2025-05-23T12:00:00Z",
"attestation_validity_seconds": 3600,
"attestation_stale": false
},
"signature": "unsigned-test-vector"
}
70 changes: 70 additions & 0 deletions tests/vectors/invalid_canonical_lone_surrogate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"cmcp_version": "1.0",
"trace": {
"eat_profile": "tag:agentrust-io.com,2026:trace-v0.2",
"iat": 1748000000,
"subject": "spiffe://cmcp.gateway/session/test-session-001",
"runtime": {
"platform": "intel-tdx",
"measurement": "sha256:a3f8d2b4e1c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8",
"nonce": "dGVzdC1ub25jZQ"
},
"policy": {
"bundle_hash": "sha256:b4e1c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4",
"enforcement_mode": "enforce",
"version": "1.0.0"
},
"data_class": "confidential",
"tool_transcript": {
"hash": "sha256:c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8",
"call_count": 5
},
"cnf": {
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"kid": "cmcp-d75a9947"
}
}
},
"gateway": {
"session_id": "\ud800",
"gateway_version": "0.1.0",
"sequence_number": 1,
"prev_claim_hash": null,
"audit_chain": {
"root": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"tip": "sha256:c9f7a5b2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f6a8",
"length": 5
},
"call_summary": {
"tool_calls_total": 5,
"tool_calls_allowed": 4,
"tool_calls_denied": 1,
"tool_calls_faulted": 0,
"tools_invoked": [
"read_file",
"write_file",
"search"
],
"session_max_sensitivity": "confidential",
"call_graph_summary": {
"compliance_domains_touched": [
"pii",
"financial"
],
"cross_boundary_events": [],
"edges_represent": "temporal-adjacency"
}
},
"catalog": {
"hash": "sha256:d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4",
"drift_detected": false
},
"attestation_generated_at": "2025-05-23T12:00:00Z",
"attestation_validity_seconds": 3600,
"attestation_stale": false
},
"signature": "unsigned-test-vector"
}
Loading
Loading