diff --git a/docs/error-codes.md b/docs/error-codes.md index bac64d3..4fb6759 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -47,7 +47,8 @@ All TRACE test failures emit a structured error code of the form `TR-- dict | None: + """Load and shape-check an anchor receipt, or return None when not supplied.""" + if path is None: + return None + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except OSError as exc: + click.echo(f"Error: cannot read receipt {path}: {exc}", err=True) + sys.exit(2) + except json.JSONDecodeError as exc: + click.echo(f"Error: receipt {path} is not valid JSON: {exc}", err=True) + sys.exit(2) + if not isinstance(data, dict): + click.echo( + f"Error: receipt {path} must be a JSON object, got {type(data).__name__}", + err=True, + ) + sys.exit(2) + return data + @click.group() @click.version_option(__version__) def main() -> None: @@ -109,7 +132,17 @@ def main() -> None: default=None, help="Verifier-issued challenge nonce; required for Level 1 and Level 2.", ) -def verify(record: str, level: int, max_age: int, expected_nonce: str | None) -> None: +@click.option( + "--receipt", + default=None, + type=click.Path(), + help=( + "Path to the anchor receipt (JSON) proving the record is included in the " + "transparency log. Required for TR-ANC-002 at Level 2: the transparency URI " + "says where the anchor lives, the receipt is what proves the record is in it." + ), +) +def verify(record: str, level: int, max_age: int, expected_nonce: str | None, receipt: str | None) -> None: """Verify a TRACE trust record against the conformance suite.""" try: data, fmt = load_record(record) @@ -117,12 +150,15 @@ def verify(record: str, level: int, max_age: int, expected_nonce: str | None) -> click.echo(f"Error: {exc}", err=True) sys.exit(2) + receipt_data = _load_receipt(receipt) + results = run( data, fmt, level, max_age_seconds=max_age, expected_nonce=expected_nonce, + receipt=receipt_data, ) exit_code = _print_report(record, fmt, level, results) sys.exit(exit_code) @@ -162,6 +198,12 @@ def verify(record: str, level: int, max_age: int, expected_nonce: str | None) -> default=None, help="Verifier-issued challenge nonce; required for Level 1 and Level 2.", ) +@click.option( + "--receipt", + default=None, + type=click.Path(), + help="Path to the anchor receipt (JSON). Required for TR-ANC-002 at Level 2.", +) def report( record: str, max_level: int, @@ -171,6 +213,7 @@ def report( badge_out: str | None, fail_under: int | None, expected_nonce: str | None, + receipt: str | None, ) -> None: """Produce a conformance report you can hand to someone else. @@ -184,6 +227,8 @@ def report( click.echo(f"Error: {exc}", err=True) sys.exit(2) + receipt_data = _load_receipt(receipt) + results_by_level = { level: run( data, @@ -191,6 +236,7 @@ def report( level, max_age_seconds=max_age, expected_nonce=expected_nonce, + receipt=receipt_data, ) for level in range(max_level + 1) } diff --git a/src/trace_tests/inclusion.py b/src/trace_tests/inclusion.py new file mode 100644 index 0000000..ede6bad --- /dev/null +++ b/src/trace_tests/inclusion.py @@ -0,0 +1,110 @@ +"""RFC 9162 inclusion-proof verification for TRACE anchor receipts. + +Standard library only, and deliberately self-contained so it can be audited or +reimplemented in isolation. This is the same algorithm as +``tools/verify_inclusion.py`` in ``agentrust-io/trace-registry``, ported here so +the conformance suite can check an anchor offline rather than trusting a URI. + +**On canonicalisation.** The leaf pre-image is sorted-key ASCII JSON, not +RFC 8785 JCS. That is not an oversight and must not be "fixed": TRACE uses two +canonicalisations by design, JCS for the signature pre-image and sorted-key +ASCII for the anchor leaf, specified in ``registry-anchor-v1.md`` section 0. A +verifier that used JCS here would recompute a different leaf and reject every +genuine proof. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + +LEAF_PREFIX = b"\x00" +NODE_PREFIX = b"\x01" +_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + +__all__ = ["InclusionError", "canonical_claim_bytes", "decode_hash", "verify_inclusion"] + + +class InclusionError(ValueError): + """The receipt is malformed, as opposed to proving nothing.""" + + +def canonical_claim_bytes(claim: dict[str, Any]) -> bytes: + """Canonical anchor-leaf JSON bytes of the complete signed claim.""" + if not isinstance(claim, dict): + raise InclusionError("claim must be a JSON object") + return json.dumps(claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + + +def decode_hash(value: object) -> bytes: + """Decode ``sha256:<64 lowercase hex>`` to 32 raw bytes.""" + if not isinstance(value, str) or not _HASH_RE.match(value): + raise InclusionError(f"malformed hash value: {value!r}") + return bytes.fromhex(value.split(":", 1)[1]) + + +def verify_inclusion( + claim: dict[str, Any], + leaf_index: int, + audit_path: list[bytes], + leaf_count: int, + merkle_root: bytes, +) -> bool: + """Return True iff *claim*'s leaf is proven included under *merkle_root*. + + RFC 9162 section 2.1.3.2 inclusion-proof verification over an RFC 6962 tree. + """ + if not isinstance(leaf_index, int) or isinstance(leaf_index, bool): + return False + if not isinstance(leaf_count, int) or isinstance(leaf_count, bool): + return False + if leaf_index < 0 or leaf_count < 1 or leaf_index >= leaf_count: + return False + + r = hashlib.sha256(LEAF_PREFIX + canonical_claim_bytes(claim)).digest() + fn, sn = leaf_index, leaf_count - 1 + + for p in audit_path: + if sn == 0: + return False # path longer than the tree height + if fn & 1 or fn == sn: + r = hashlib.sha256(NODE_PREFIX + p + r).digest() + if not fn & 1: + # Right edge: skip levels whose ancestor was promoted unpaired. + while fn and not fn & 1: + fn >>= 1 + sn >>= 1 + else: + r = hashlib.sha256(NODE_PREFIX + r + p).digest() + fn >>= 1 + sn >>= 1 + + return sn == 0 and r == merkle_root + + +def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]: + """Validate a receipt object and return (leaf_index, audit_path, leaf_count, merkle_root). + + Raises InclusionError with a specific reason rather than returning a bare + False, so a malformed receipt and a receipt that proves nothing are + reported differently. + """ + if not isinstance(receipt, dict): + raise InclusionError(f"receipt must be a JSON object, got {type(receipt).__name__}") + + missing = [k for k in ("leaf_index", "audit_path", "leaf_count", "merkle_root") if k not in receipt] + if missing: + raise InclusionError(f"receipt is missing required field(s): {', '.join(missing)}") + + raw_path = receipt["audit_path"] + if not isinstance(raw_path, list): + raise InclusionError(f"audit_path must be an array, got {type(raw_path).__name__}") + + return ( + receipt["leaf_index"], + [decode_hash(node) for node in raw_path], + receipt["leaf_count"], + decode_hash(receipt["merkle_root"]), + ) diff --git a/src/trace_tests/modules/tr_anc.py b/src/trace_tests/modules/tr_anc.py index d9968eb..fb67aaa 100644 --- a/src/trace_tests/modules/tr_anc.py +++ b/src/trace_tests/modules/tr_anc.py @@ -1,15 +1,23 @@ -"""TR-ANC: Transparency anchoring checks (spec §3.2).""" +"""TR-ANC: Transparency anchoring checks (spec section 3.2).""" from __future__ import annotations from typing import Any from urllib.parse import urlparse +from trace_tests.inclusion import InclusionError, parse_receipt, verify_inclusion from trace_tests.result import Finding, Status -def check(trace: dict[str, Any]) -> list[Finding]: - """Return TR-ANC findings for the transparency claim.""" +def check(trace: dict[str, Any], receipt: dict[str, Any] | None = None) -> list[Finding]: + """Return TR-ANC findings for the transparency claim. + + TR-ANC-001 checks the shape of the ``transparency`` URI. TR-ANC-002 checks + that the record is actually anchored, by replaying the inclusion proof in + *receipt* against the committed Merkle root. Without a receipt there is + nothing to replay, and TR-ANC-002 fails: Level 2 means anchored, and a URI + is a pointer at an anchor rather than evidence of one. + """ findings: list[Finding] = [] transparency = trace.get("transparency") @@ -21,14 +29,53 @@ def check(trace: dict[str, Any]) -> list[Finding]: try: parsed = urlparse(transparency) - if parsed.scheme == "https" and parsed.netloc: - findings.append(Finding("TR-ANC-001", Status.PASS, f"transparency is a valid URI ({transparency[:80]})")) - else: - findings.append(Finding( - "TR-ANC-001", Status.FAIL, - f"TR-ANC-001: transparency must be an https URI, got scheme={parsed.scheme!r}", - )) except Exception as exc: - findings.append(Finding("TR-ANC-001", Status.FAIL, f"TR-ANC-001: could not parse transparency URI: {exc}")) + return [Finding("TR-ANC-001", Status.FAIL, f"TR-ANC-001: could not parse transparency URI: {exc}")] + + if parsed.scheme != "https" or not parsed.netloc: + return [Finding( + "TR-ANC-001", Status.FAIL, + f"TR-ANC-001: transparency must be an https URI, got scheme={parsed.scheme!r}", + )] + findings.append(Finding( + "TR-ANC-001", Status.PASS, + f"transparency is a well-formed https URI ({transparency[:80]}); " + "this checks the pointer, not the anchor (see TR-ANC-002)", + )) + findings.append(_check_inclusion(trace, receipt)) return findings + + +def _check_inclusion(trace: dict[str, Any], receipt: dict[str, Any] | None) -> Finding: + """Replay the inclusion proof, or say why it could not be replayed.""" + if receipt is None: + return Finding( + "TR-ANC-002", Status.FAIL, + "TR-ANC-002: no anchor receipt supplied, so inclusion was not proven. " + "The transparency URI names where the anchor lives; it is not evidence " + "the record is in it. Pass the receipt with --receipt.", + ) + + try: + leaf_index, audit_path, leaf_count, merkle_root = parse_receipt(receipt) + except InclusionError as exc: + return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: malformed anchor receipt: {exc}") + + try: + proven = verify_inclusion(trace, leaf_index, audit_path, leaf_count, merkle_root) + except InclusionError as exc: + return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: could not verify inclusion: {exc}") + + if proven: + return Finding( + "TR-ANC-002", Status.PASS, + f"inclusion proven against merkle_root {merkle_root.hex()[:16]}... " + f"(leaf {leaf_index} of {leaf_count})", + ) + return Finding( + "TR-ANC-002", Status.FAIL, + f"TR-ANC-002: inclusion proof does not reproduce the committed merkle_root " + f"(leaf {leaf_index} of {leaf_count}). The record is not in the tree this " + "receipt commits to, or it has been modified since it was anchored.", + ) diff --git a/src/trace_tests/runner.py b/src/trace_tests/runner.py index 8689ef0..f8f318e 100644 --- a/src/trace_tests/runner.py +++ b/src/trace_tests/runner.py @@ -22,6 +22,7 @@ def run( level: int, max_age_seconds: int = tr_env.DEFAULT_MAX_AGE_SECONDS, expected_nonce: str | None = None, + receipt: dict[str, Any] | None = None, ) -> dict[str, list[Finding]]: """Run all modules required for *level* and return findings keyed by module ID.""" if level not in _LEVEL_MODULES: @@ -51,6 +52,6 @@ def run( results["TR-TXN"] = tr_txn.check(trace) if "TR-ANC" in active: - results["TR-ANC"] = tr_anc.check(trace) + results["TR-ANC"] = tr_anc.check(trace, receipt=receipt) return results diff --git a/tests/test_tr_anc_inclusion.py b/tests/test_tr_anc_inclusion.py new file mode 100644 index 0000000..3223736 --- /dev/null +++ b/tests/test_tr_anc_inclusion.py @@ -0,0 +1,177 @@ +"""TR-ANC inclusion-proof tests (#70). + +The bug: Level 2 passed on any string that parsed as an https URI, so +`https://example.invalid/nothing-here` earned an anchoring badge. These tests +hold TR-ANC to what section 3.2 actually asks for. +""" + +from __future__ import annotations + +import hashlib +import json + +import pytest + +from trace_tests.inclusion import ( + InclusionError, + canonical_claim_bytes, + parse_receipt, + verify_inclusion, +) +from trace_tests.modules import tr_anc +from trace_tests.result import Status + +LEAF = b"\x00" +NODE = b"\x01" + + +def _leaf(claim: dict) -> bytes: + return hashlib.sha256(LEAF + canonical_claim_bytes(claim)).digest() + + +def _tree(claims: list[dict]) -> tuple[bytes, list[list[bytes]]]: + """Build an RFC 6962 tree, returning (root, levels).""" + level = [_leaf(c) for c in claims] + levels = [level] + while len(level) > 1: + nxt = [] + for i in range(0, len(level) - 1, 2): + nxt.append(hashlib.sha256(NODE + level[i] + level[i + 1]).digest()) + if len(level) % 2: + nxt.append(level[-1]) # promoted unpaired, per the anchor format + level = nxt + levels.append(level) + return level[0], levels + + +def _audit_path(levels: list[list[bytes]], index: int) -> list[str]: + path, idx = [], index + for level in levels[:-1]: + sib = idx ^ 1 + if sib < len(level): + path.append("sha256:" + level[sib].hex()) + idx //= 2 + return path + + +def _receipt(claims: list[dict], index: int) -> dict: + root, levels = _tree(claims) + return { + "leaf_index": index, + "leaf_count": len(claims), + "audit_path": _audit_path(levels, index), + "merkle_root": "sha256:" + root.hex(), + } + + +def _trace(n: int = 0) -> dict: + return {"transparency": "https://log.example.com/entries/1", "iat": 1000 + n, "subject": f"agent-{n}"} + + +def _codes(findings) -> dict[str, Status]: + return {f.code: f.status for f in findings} + + +# --- the reported bug ------------------------------------------------------ + +def test_a_uri_pointing_nowhere_no_longer_earns_level_2(): + """#70: this exact record passed before. It must not now.""" + trace = {"transparency": "https://example.invalid/nothing-here"} + codes = _codes(tr_anc.check(trace)) + assert codes["TR-ANC-001"] is Status.PASS, "the URI really is well-formed" + assert codes["TR-ANC-002"] is Status.FAIL, "but nothing proves the record is anchored" + + +def test_the_pass_message_no_longer_overstates_what_was_checked(): + trace = {"transparency": "https://example.invalid/nothing-here"} + anc001 = next(f for f in tr_anc.check(trace) if f.code == "TR-ANC-001") + assert "pointer" in anc001.message + + +# --- real proofs ----------------------------------------------------------- + +@pytest.mark.parametrize("count,index", [(1, 0), (2, 0), (2, 1), (3, 2), (5, 3), (8, 7), (9, 8)]) +def test_a_genuine_inclusion_proof_passes(count, index): + claims = [_trace(i) for i in range(count)] + codes = _codes(tr_anc.check(claims[index], receipt=_receipt(claims, index))) + assert codes["TR-ANC-002"] is Status.PASS + + +def test_a_proof_for_a_different_record_fails(): + claims = [_trace(i) for i in range(4)] + codes = _codes(tr_anc.check(claims[0], receipt=_receipt(claims, 1))) + assert codes["TR-ANC-002"] is Status.FAIL + + +def test_a_record_modified_after_anchoring_fails(): + claims = [_trace(i) for i in range(4)] + receipt = _receipt(claims, 2) + tampered = dict(claims[2]) + tampered["subject"] = "agent-elsewhere" + assert _codes(tr_anc.check(tampered, receipt=receipt))["TR-ANC-002"] is Status.FAIL + + +def test_a_forged_root_fails(): + claims = [_trace(i) for i in range(4)] + receipt = _receipt(claims, 1) + receipt["merkle_root"] = "sha256:" + "00" * 32 + assert _codes(tr_anc.check(claims[1], receipt=receipt))["TR-ANC-002"] is Status.FAIL + + +# --- malformed receipts are reported as malformed, not as "not included" ---- + +@pytest.mark.parametrize("field", ["leaf_index", "audit_path", "leaf_count", "merkle_root"]) +def test_a_receipt_missing_a_field_says_which(field): + claims = [_trace(i) for i in range(4)] + receipt = _receipt(claims, 1) + del receipt[field] + finding = next(f for f in tr_anc.check(claims[1], receipt=receipt) if f.code == "TR-ANC-002") + assert finding.status is Status.FAIL + assert "malformed" in finding.message and field in finding.message + + +def test_an_out_of_range_leaf_index_fails_rather_than_raising(): + claims = [_trace(i) for i in range(4)] + receipt = _receipt(claims, 1) + receipt["leaf_index"] = 99 + assert _codes(tr_anc.check(claims[1], receipt=receipt))["TR-ANC-002"] is Status.FAIL + + +def test_a_boolean_is_not_an_integer_leaf_index(): + assert verify_inclusion({"a": 1}, True, [], 2, b"\x00" * 32) is False + + +def test_a_non_hex_audit_node_is_malformed(): + with pytest.raises(InclusionError): + parse_receipt({"leaf_index": 0, "leaf_count": 1, "audit_path": ["not-a-hash"], + "merkle_root": "sha256:" + "00" * 32}) + + +# --- the canonicalisation that must not be "fixed" ------------------------- + +def test_the_leaf_pre_image_is_sorted_key_ascii_not_jcs(): + """TRACE canonicalises twice by design; the anchor leaf is not JCS. + + A non-ASCII string is where the two disagree, and swapping one for the + other silently invalidates every genuine proof. + """ + claim = {"subject": "agent-\u00e9"} + assert canonical_claim_bytes(claim) == json.dumps( + claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii") + assert rb"\u00e9" in canonical_claim_bytes(claim) + + +# --- shape checks still work ---------------------------------------------- + +@pytest.mark.parametrize("value", [None, "", 0]) +def test_a_missing_transparency_field_still_fails(value): + assert _codes(tr_anc.check({"transparency": value}))["TR-ANC-001"] is Status.FAIL + + +def test_a_non_https_uri_still_fails(): + assert _codes(tr_anc.check({"transparency": "http://log.example.com/1"}))["TR-ANC-001"] is Status.FAIL + + +def test_a_non_string_transparency_still_fails(): + assert _codes(tr_anc.check({"transparency": 42}))["TR-ANC-001"] is Status.FAIL diff --git a/tests/unit/test_tr_anc.py b/tests/unit/test_tr_anc.py index 3b8b030..d6dde3b 100644 --- a/tests/unit/test_tr_anc.py +++ b/tests/unit/test_tr_anc.py @@ -3,9 +3,17 @@ from trace_tests.modules.tr_anc import check -def test_https_transparency_passes(): +def test_https_transparency_passes_the_shape_check_only(): + """A well-formed URI clears TR-ANC-001 and cannot clear TR-ANC-002 alone. + + Before #70 this asserted every finding passed, which is what let + ``https://example.invalid/nothing-here`` earn a Level 2 badge. The URI says + where the anchor lives; only a receipt shows the record is in it. + """ findings = check({"transparency": "https://scitt.example.org/receipts/abc123"}) - assert all(f.passed() for f in findings), findings + by_code = {f.code: f for f in findings} + assert by_code["TR-ANC-001"].passed() + assert by_code["TR-ANC-002"].failed() def test_http_transparency_fails():