From 30227ce901df90ec2bdf8d74fb4b2dfaa55f4093 Mon Sep 17 00:00:00 2001 From: lywinged <48041247+lywinged@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:57:14 +0000 Subject: [PATCH] fix(tr-sig): canonicalize with RFC 8785, which is the rule this suite scores TR-SIG verified signatures over json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True) which specification section 3.2.2 names, in as many words, as insufficient: "Implementations MUST use an RFC 8785-conformant library. Using json.dumps(sort_keys=True) (Python) or equivalent ad-hoc sorting is insufficient." The suite that scores conformance was failing the one requirement it had no way to see itself failing. The two forms agree byte-for-byte on any record whose strings are ASCII, and every record this suite carried was ASCII, so all 176 tests passed while the runner rejected valid records. Pointed at trace-spec's examples/canonicalization-boundary/ -- four schema-valid records, correctly signed over their RFC 8785 bytes, each declaring expected outcome "verified" -- it failed all four. Direction matters for reading the severity: this rejects valid records, it does not accept invalid ones, so it is an interoperability defect rather than a security hole. What it costs is real anyway. Any record carrying a non-ASCII character in any string value -- a data_class, a model version, a subject path, any human-language field -- is reported by the official suite as having a failed signature, which tells a conformant implementation that it is not one. The fix is the one function. agentrust_trace.sign already canonicalizes with rfc8785.dumps, so the signer and the verifier were using different rules; rfc8785 joins the dependencies for the verifier to use the same one. The four vectors come in as regression material, copied rather than vendored. Nothing compares them to the originals: a guard that needs another repository checked out is a guard that gets skipped. Each is instead held to the two properties that make it worth having -- it verifies over its own RFC 8785 bytes, and it does not verify under any ad-hoc form its diverges_under names -- so a vector edited into something that is no longer a boundary fails rather than sitting in the directory looking like coverage. A fifth test compares _canonical_json's output to the reference directly, on a probe every ad-hoc form gets wrong, so a reintroduced shortcut is reported as a serializer problem rather than as a failed signature. Verified by reverting the one-line change: the four runner assertions and the direct byte comparison fail, and the eight vector self-validation assertions pass, which is the shape a regression test should have. The module docstring also claimed plain trace records could not be cryptographically verified, while check() has verified them against an embedded signature field for some time. Corrected in the same pass, since a reader following it would not have looked at the path this commit fixes. 190 passed. The six ruff findings in this file are the six that were there before; the line numbers moved. Signed-off-by: lywinged <48041247+lywinged@users.noreply.github.com> --- pyproject.toml | 3 + src/trace_tests/modules/tr_sig.py | 36 +++-- tests/test_canonicalization_boundary.py | 150 ++++++++++++++++++ .../canonicalization/01-non-ascii-values.json | 51 ++++++ .../canonicalization/02-non-bmp-values.json | 51 ++++++ .../canonicalization/03-utf16-key-order.json | 53 +++++++ .../04-utf16-key-order-nested.json | 55 +++++++ 7 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 tests/test_canonicalization_boundary.py create mode 100644 tests/vectors/canonicalization/01-non-ascii-values.json create mode 100644 tests/vectors/canonicalization/02-non-bmp-values.json create mode 100644 tests/vectors/canonicalization/03-utf16-key-order.json create mode 100644 tests/vectors/canonicalization/04-utf16-key-order-nested.json diff --git a/pyproject.toml b/pyproject.toml index 380e1c7..da3f9ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ dependencies = [ "click>=8.1", "cryptography>=42", "jsonschema>=4.23", + # Signature verification canonicalizes with RFC 8785, which specification + # §3.2.2 requires by name and rules out doing by hand. + "rfc8785>=0.1.2", ] [project.urls] diff --git a/src/trace_tests/modules/tr_sig.py b/src/trace_tests/modules/tr_sig.py index c9d40cc..ac40add 100644 --- a/src/trace_tests/modules/tr_sig.py +++ b/src/trace_tests/modules/tr_sig.py @@ -1,20 +1,30 @@ """TR-SIG: Signature verification (spec §3.2.1). -For cmcp-runtime records: Ed25519 over canonical JSON (sorted keys, no whitespace, -excluding the ``signature`` field). Key is in ``trace.cnf.jwk``. - -For plain trace records no signature can be cryptographically verified, so TR-SIG -fails closed: at any level that requires signatures (level >= 1) the result is FAIL; -at level 0 the result is an explicit UNVERIFIED finding so the record can never be -reported as cryptographically verified. +Records are verified over their RFC 8785 (JCS) canonical bytes, excluding the +``signature`` field, with the key in ``trace.cnf.jwk``. + +RFC 8785 and not an ad-hoc serializer, because §3.2.2 of the specification says so +in as many words: "Implementations MUST use an RFC 8785-conformant library. Using +``json.dumps(sort_keys=True)`` (Python) or equivalent ad-hoc sorting is +insufficient." This module used ``json.dumps(sort_keys=True, separators=(",", ":"), +ensure_ascii=True)`` until it was pointed at trace-spec's canonicalization corpus, +which rejected all four of its valid, correctly signed records. The two forms agree +on every ASCII record, which is every record this suite carried, so nothing here +failed while the suite told conformant implementations they were not. + +A plain trace record carrying a ``signature`` field is verified against it. One +without a signature cannot be, so TR-SIG fails closed: at any level that requires +signatures (level >= 1) the result is FAIL; at level 0 it is an explicit UNVERIFIED +finding, so a record with nothing to check is never reported as verified. """ from __future__ import annotations import base64 -import json from typing import Any +import rfc8785 + from trace_tests.result import Finding, Status _SUPPORTED_KTY = {"OKP", "EC"} @@ -29,7 +39,15 @@ def _b64url_decode(s: str) -> bytes: def _canonical_json(d: dict[str, Any]) -> bytes: - return json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + """RFC 8785 canonical bytes, per specification §3.2.2. + + Not ``json.dumps`` with its options set carefully. The escaping of non-ASCII and + the sort order of keys containing supplementary-plane characters both differ, and + a verifier that gets either wrong computes different bytes and rejects a valid + record. ``tests/test_canonicalization_boundary.py`` holds this to records where + the difference is observable. + """ + return rfc8785.dumps(d) def _verify_ed25519(pub_x: str, sig_b64: str, body: bytes) -> tuple[bool, str]: diff --git a/tests/test_canonicalization_boundary.py b/tests/test_canonicalization_boundary.py new file mode 100644 index 0000000..391eb74 --- /dev/null +++ b/tests/test_canonicalization_boundary.py @@ -0,0 +1,150 @@ +"""Records on which RFC 8785 and a careful ``json.dumps`` disagree. + +Specification §3.2.2: "Implementations MUST use an RFC 8785-conformant library. +Using ``json.dumps(sort_keys=True)`` (Python) or equivalent ad-hoc sorting is +insufficient." Until these vectors arrived this suite verified signatures with +``json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True)`` and every +test passed, because the two forms agree byte-for-byte on ASCII records and every +record here was ASCII. The suite that scores conformance was failing the one +requirement it could not see itself failing. + +The vectors are copied from trace-spec's ``examples/canonicalization-boundary/`` +rather than vendored, and nothing here compares them to the originals — a guard +that needs another repository checked out is a guard that gets skipped. Instead +each vector is held to the two properties that make it worth having: + + it is genuinely valid — the signature verifies over the record's RFC 8785 bytes; + it is genuinely a boundary — the signature does *not* verify over the bytes each + ad-hoc form in ``diverges_under`` produces. + +A vector edited into something that is no longer a boundary fails the second +assertion rather than sitting in the directory looking like coverage. + +``diverges_under`` names rungs of a ladder, each needing a sharper record to reach: + + ``sort_keys_default`` ``json.dumps(sort_keys=True)`` — spaces after separators + ``sort_keys_compact`` adds ``separators=(",", ":")`` — escapes non-ASCII + ``sort_keys_compact_utf8`` adds ``ensure_ascii=False`` — still sorts by code point, + where RFC 8785 sorts by UTF-16 code unit +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from trace_tests.modules import tr_sig +from trace_tests.runner import run as run_conformance + +VECTOR_DIR = Path(__file__).parent / "vectors" / "canonicalization" +VECTORS = sorted(VECTOR_DIR.glob("*.json")) + +# The ad-hoc serializers §3.2.2 rules out, as the vectors name them. +AD_HOC = { + "sort_keys_default": lambda d: json.dumps(d, sort_keys=True).encode(), + "sort_keys_compact": lambda d: json.dumps( + d, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(), + "sort_keys_compact_utf8": lambda d: json.dumps( + d, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8"), +} + +# Old enough that the freshness bound never decides one of these tests. +MAX_AGE = 100 * 365 * 24 * 3600 + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _b64u(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _verifies_over(record: dict[str, Any], body: bytes) -> bool: + key = Ed25519PublicKey.from_public_bytes(_b64u(record["cnf"]["jwk"]["x"])) + try: + key.verify(_b64u(record["signature"]), body) + except InvalidSignature: + return False + return True + + +def _body(record: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in record.items() if k != "signature"} + + +def test_the_vector_set_is_present() -> None: + """A directory that lost its contents passes every test parametrised on it.""" + assert [p.name for p in VECTORS] == [ + "01-non-ascii-values.json", + "02-non-bmp-values.json", + "03-utf16-key-order.json", + "04-utf16-key-order-nested.json", + ] + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: p.stem) +def test_the_record_is_genuinely_valid(path: Path) -> None: + """Half of what makes a boundary vector a boundary. Without this the vector + could be rejected by everything, including a correct verifier, and the test + below would still pass.""" + record = _load(path)["record"] + assert _verifies_over(record, rfc8785.dumps(_body(record))), ( + f"{path.name} does not verify over its own RFC 8785 bytes; it is not a " + "valid record and cannot demonstrate anything about a verifier" + ) + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: p.stem) +def test_the_record_is_genuinely_a_boundary(path: Path) -> None: + """The other half: each named ad-hoc form must actually fail on this record. + + A vector whose divergences have quietly stopped diverging is a vector that no + longer separates a conformant verifier from a non-conformant one, and it would + keep passing every other test in this file. + """ + vector = _load(path) + record = vector["record"] + for name in vector["diverges_under"]: + assert not _verifies_over(record, AD_HOC[name](_body(record))), ( + f"{path.name} still verifies under {name!r}, so it no longer " + "distinguishes that serializer from RFC 8785" + ) + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: p.stem) +def test_the_runner_accepts_it(path: Path) -> None: + """The regression this file exists for: all four of these were reported as + signature failures by the shipped runner.""" + vector = _load(path) + findings = run_conformance(vector["record"], "trace", level=0, max_age_seconds=MAX_AGE) + signature = next(f for f in findings["TR-SIG"] if f.code == "TR-SIG-005") + assert signature.passed(), ( + f"{path.name} is a valid, correctly signed record and the runner rejected " + f"it: {signature.message}" + ) + + +def test_the_module_canonicalizes_with_rfc_8785() -> None: + """Aimed at the function rather than at its effect. + + The vectors above catch a wrong serializer through a failed signature, which is + one step removed and reads as a key problem. This compares the bytes directly on + an input chosen so that every ad-hoc form differs, so a reintroduced shortcut is + reported as what it is. + """ + probe = {"z\U0001f600": "supplementary-plane key", "z�": "replacement char", + "value": "modèle-géant"} + assert tr_sig._canonical_json(probe) == rfc8785.dumps(probe) + for name, serializer in AD_HOC.items(): + assert tr_sig._canonical_json(probe) != serializer(probe), ( + f"the probe does not separate {name!r} from RFC 8785, so this test " + "would pass against a verifier using it" + ) diff --git a/tests/vectors/canonicalization/01-non-ascii-values.json b/tests/vectors/canonicalization/01-non-ascii-values.json new file mode 100644 index 0000000..32cb227 --- /dev/null +++ b/tests/vectors/canonicalization/01-non-ascii-values.json @@ -0,0 +1,51 @@ +{ + "name": "non-ascii-values", + "description": "String values outside ASCII, all in the Basic Multilingual Plane. RFC 8785 emits them as literal UTF-8; a serializer that escapes to \\uXXXX signs different bytes and rejects this valid record.", + "spec": "trace-v0.2 section 3.2.2 — implementations MUST use an RFC 8785-conformant library", + "diverges_under": [ + "sort_keys_default", + "sort_keys_compact" + ], + "expected_tr_sig": "PASS", + "source": { + "repository": "agentrust-io/trace-spec", + "path": "examples/canonicalization-boundary/01-non-ascii-values.json", + "note": "Copied, not vendored. `test_canonicalization_boundary.py` asserts the two properties that make this record useful rather than comparing it to the original, so an edit that stops it being a boundary fails here without needing the other repository present." + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "version": "modèle-géant-4.6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "机密", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "Be97jkxfFpVXzj9B-gwpMzv5t8PH30Edd-J7AIlrdoA" + } + }, + "signature": "WehNEF0FqgUa_c85Hw7jbbz4_d_kg2GEyo4r4p242CNGjTkmmRNvVPuwTfjtKJwbOCuNspqEyrMNgZMOTh-OAA" + } +} diff --git a/tests/vectors/canonicalization/02-non-bmp-values.json b/tests/vectors/canonicalization/02-non-bmp-values.json new file mode 100644 index 0000000..a298337 --- /dev/null +++ b/tests/vectors/canonicalization/02-non-bmp-values.json @@ -0,0 +1,51 @@ +{ + "name": "non-bmp-values", + "description": "String values above U+FFFF, encoded as four UTF-8 bytes each. Under ASCII-escaping they become surrogate pairs; either way the bytes differ from RFC 8785's literal UTF-8.", + "spec": "trace-v0.2 section 3.2.2 — implementations MUST use an RFC 8785-conformant library", + "diverges_under": [ + "sort_keys_default", + "sort_keys_compact" + ], + "expected_tr_sig": "PASS", + "source": { + "repository": "agentrust-io/trace-spec", + "path": "examples/canonicalization-boundary/02-non-bmp-values.json", + "note": "Copied, not vendored. `test_canonicalization_boundary.py` asserts the two properties that make this record useful rather than comparing it to the original, so an edit that stops it being a boundary fails here without needing the other repository present." + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + "version": "4.6-🤖" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential-🔒", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "Be97jkxfFpVXzj9B-gwpMzv5t8PH30Edd-J7AIlrdoA" + } + }, + "signature": "62CaOUWmDFPmgthTUkJ4cdwxmDQXzYg9hN6KaCB3EHjeDzeLiB_rdVFIRQrDVTzt-clmIoxNs7UxzJMFvWB_Bw" + } +} diff --git a/tests/vectors/canonicalization/03-utf16-key-order.json b/tests/vectors/canonicalization/03-utf16-key-order.json new file mode 100644 index 0000000..e2b336e --- /dev/null +++ b/tests/vectors/canonicalization/03-utf16-key-order.json @@ -0,0 +1,53 @@ +{ + "name": "utf16-key-order", + "description": "Two object keys whose order under RFC 8785's UTF-16 code-unit sort is the reverse of their code-point order. This is the record that distinguishes a true RFC 8785 serializer from json.dumps with every option set carefully: compact separators and ensure_ascii=False survive vectors 01 and 02, and fail here.", + "spec": "trace-v0.2 section 3.2.2 — implementations MUST use an RFC 8785-conformant library", + "diverges_under": [ + "sort_keys_default", + "sort_keys_compact", + "sort_keys_compact_utf8" + ], + "expected_tr_sig": "PASS", + "source": { + "repository": "agentrust-io/trace-spec", + "path": "examples/canonicalization-boundary/03-utf16-key-order.json", + "note": "Copied, not vendored. `test_canonicalization_boundary.py` asserts the two properties that make this record useful rather than comparing it to the original, so an edit that stops it being a boundary fails here without needing the other repository present." + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "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": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "Be97jkxfFpVXzj9B-gwpMzv5t8PH30Edd-J7AIlrdoA", + "zk😀": "sorts-first-under-rfc-8785", + "zk�": "sorts-second-under-rfc-8785" + } + }, + "signature": "CjOuPwCnxnwegFjguiSCi-_xPg3iOwnCgyKuKYnV0OorofjPJrkOLn3dUFa-6tVf0z8EDiHaczl6AN46MuBtCQ" + } +} diff --git a/tests/vectors/canonicalization/04-utf16-key-order-nested.json b/tests/vectors/canonicalization/04-utf16-key-order-nested.json new file mode 100644 index 0000000..4cafcb7 --- /dev/null +++ b/tests/vectors/canonicalization/04-utf16-key-order-nested.json @@ -0,0 +1,55 @@ +{ + "name": "utf16-key-order-nested", + "description": "The divergence of vector 03 moved inside a nested object, so that a canonicalizer sorting by UTF-16 code units at the outer levels and by code points below them passes 03 and fails here. Without it the closest non-conformant form is caught by one vector, and the boundary disappears with that vector.", + "spec": "trace-v0.2 section 3.2.2 — implementations MUST use an RFC 8785-conformant library", + "diverges_under": [ + "sort_keys_default", + "sort_keys_compact", + "sort_keys_compact_utf8" + ], + "expected_tr_sig": "PASS", + "source": { + "repository": "agentrust-io/trace-spec", + "path": "examples/canonicalization-boundary/04-utf16-key-order-nested.json", + "note": "Copied, not vendored. `test_canonicalization_boundary.py` asserts the two properties that make this record useful rather than comparing it to the original, so an edit that stops it being a boundary fails here without needing the other repository present." + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "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": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "Be97jkxfFpVXzj9B-gwpMzv5t8PH30Edd-J7AIlrdoA", + "zmeta": { + "zk😀": "sorts-first-under-rfc-8785", + "zk�": "sorts-second-under-rfc-8785" + } + } + }, + "signature": "yXsht9nU--Hvr8K7xHq72MOU6xyVhsCKw0_YcAdDff641JNlPG1d2qAZ_zwXaLe48agijvRk3MVZioG85aAiBg" + } +}