diff --git a/src/trace_tests/modules/tr_sig.py b/src/trace_tests/modules/tr_sig.py index 071d776..2b9ebdc 100644 --- a/src/trace_tests/modules/tr_sig.py +++ b/src/trace_tests/modules/tr_sig.py @@ -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)``. @@ -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)) @@ -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, diff --git a/tests/test_canonicalization_refusals.py b/tests/test_canonicalization_refusals.py new file mode 100644 index 0000000..d854a66 --- /dev/null +++ b/tests/test_canonicalization_refusals.py @@ -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}" + ) diff --git a/tests/test_modules_never_raise.py b/tests/test_modules_never_raise.py index f03b830..8a49b41 100644 --- a/tests/test_modules_never_raise.py +++ b/tests/test_modules_never_raise.py @@ -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", diff --git a/tests/vectors/invalid_canonical_integer_out_of_range.json b/tests/vectors/invalid_canonical_integer_out_of_range.json new file mode 100644 index 0000000..dd3fa2e --- /dev/null +++ b/tests/vectors/invalid_canonical_integer_out_of_range.json @@ -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" +} diff --git a/tests/vectors/invalid_canonical_lone_surrogate.json b/tests/vectors/invalid_canonical_lone_surrogate.json new file mode 100644 index 0000000..50d71b4 --- /dev/null +++ b/tests/vectors/invalid_canonical_lone_surrogate.json @@ -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" +} diff --git a/tests/vectors/invalid_canonical_non_finite_float.json b/tests/vectors/invalid_canonical_non_finite_float.json new file mode 100644 index 0000000..cf60606 --- /dev/null +++ b/tests/vectors/invalid_canonical_non_finite_float.json @@ -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": 5, + "tool_calls_allowed": 4, + "tool_calls_denied": 1, + "tool_calls_faulted": 1e400, + "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" +} diff --git a/tests/vectors/invalid_canonical_plain_trace.json b/tests/vectors/invalid_canonical_plain_trace.json new file mode 100644 index 0000000..89253f8 --- /dev/null +++ b/tests/vectors/invalid_canonical_plain_trace.json @@ -0,0 +1,34 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 100000000000000000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" +}