diff --git a/conformance/docs/modules/tr-anc.md b/conformance/docs/modules/tr-anc.md index 78a9adf8..032c9752 100644 --- a/conformance/docs/modules/tr-anc.md +++ b/conformance/docs/modules/tr-anc.md @@ -18,4 +18,4 @@ Checks that the record was entered in a public, append-only log, so it cannot be | Test ID | Description | Positive Case | Negative Case | |---------|-------------|---------------|---------------| | TR-ANC-001 | `transparency` is an `https://` URI with a host. Not resolved | `https://transparency.example/entries/abc123` | missing field, empty string, non-string, `http://`, bare path, `ipfs://` | -| TR-ANC-002 | The receipt's `merkle_root` and `leaf_count` equal the supplied registry entry's, and the record's inclusion proof replays to that `merkle_root`, per RFC 9162 over an RFC 6962 tree. Offline, no network. The link from the `transparency` URI to the entry is not checked | a receipt whose `audit_path` reproduces `merkle_root` from the record's leaf, with the entry sealed alongside it | no receipt supplied, no entry supplied, malformed entry, receipt `merkle_root` or `leaf_count` differing from the entry's, missing `leaf_index`/`audit_path`/`leaf_count`/`merkle_root`, non-hex audit node, out-of-range `leaf_index`, proof for a different record, record modified after anchoring | +| TR-ANC-002 | The record's inclusion proof (`leaf_index` and `audit_path`) replays to the supplied registry entry's `merkle_root` and `leaf_count`, and any `merkle_root` or `leaf_count` the receipt carries equals the entry's, per RFC 9162 over an RFC 6962 tree. Offline, no network. The link from the `transparency` URI to the entry is not checked | a receipt whose `audit_path` reproduces the entry's `merkle_root` from the record's leaf, with or without its own copy of `merkle_root` and `leaf_count` | no receipt supplied, no entry supplied, malformed entry, a receipt-carried `merkle_root` or `leaf_count` differing from the entry's, missing `leaf_index`/`audit_path`, non-hex audit node, out-of-range `leaf_index`, proof for a different record, record modified after anchoring | diff --git a/conformance/src/trace_tests/inclusion.py b/conformance/src/trace_tests/inclusion.py index 7e13a443..07e16352 100644 --- a/conformance/src/trace_tests/inclusion.py +++ b/conformance/src/trace_tests/inclusion.py @@ -133,8 +133,13 @@ def verify_inclusion( 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). +def parse_proof(receipt: object) -> tuple[int, list[bytes], int | None, bytes | None]: + """Validate a receipt and return (leaf_index, audit_path, leaf_count, merkle_root). + + The section 5 proof is ``leaf_index`` and ``audit_path``, and both are + required. ``leaf_count`` and ``merkle_root`` belong to the registry entry + (section 5.1); a receipt may carry its own copies (section 6), and each is + returned when present and ``None`` when absent. Raises InclusionError with a specific reason rather than returning a bare False, so a malformed receipt and a receipt that proves nothing are @@ -143,9 +148,7 @@ def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]: 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 - ] + missing = [k for k in ("leaf_index", "audit_path") if k not in receipt] if missing: raise InclusionError(f"receipt is missing required field(s): {', '.join(missing)}") @@ -153,14 +156,40 @@ def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]: if not isinstance(raw_path, list): raise InclusionError(f"audit_path must be an array, got {type(raw_path).__name__}") + leaf_count = receipt.get("leaf_count") + if "leaf_count" in receipt and (type(leaf_count) is not int): + raise InclusionError( + f"receipt leaf_count must be an integer, got {type(leaf_count).__name__}" + ) + return ( receipt["leaf_index"], [decode_hash(node) for node in raw_path], - receipt["leaf_count"], - decode_hash(receipt["merkle_root"]), + leaf_count, + decode_hash(receipt["merkle_root"]) if "merkle_root" in receipt else None, ) +def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]: + """Validate a self-contained receipt: (leaf_index, audit_path, leaf_count, merkle_root). + + For callers that replay a receipt without an entry, such as the published + anchor-inclusion vectors, so the receipt must carry ``leaf_count`` and + ``merkle_root`` itself. + """ + if isinstance(receipt, dict): + 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)}") + leaf_index, audit_path, leaf_count, merkle_root = parse_proof(receipt) + if leaf_count is None or merkle_root is None: + raise InclusionError("receipt leaf_count and merkle_root must not be null") + return leaf_index, audit_path, leaf_count, merkle_root + + _ENTRY_STRINGS = ("ts", "producer", "batch_id") diff --git a/conformance/src/trace_tests/modules/tr_anc.py b/conformance/src/trace_tests/modules/tr_anc.py index e0f2565c..410173d7 100644 --- a/conformance/src/trace_tests/modules/tr_anc.py +++ b/conformance/src/trace_tests/modules/tr_anc.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse -from trace_tests.inclusion import InclusionError, parse_entry, parse_receipt, verify_inclusion +from trace_tests.inclusion import InclusionError, parse_entry, parse_proof, verify_inclusion from trace_tests.result import Finding, Status @@ -17,9 +17,11 @@ def check( """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: the receipt's ``merkle_root`` and - ``leaf_count`` must equal those of the registry *entry* (registry-anchor-v1 - section 4), and the inclusion proof in *receipt* must replay to that root. + that the record is actually anchored: the inclusion proof in *receipt* + (``leaf_index`` and ``audit_path``, registry-anchor-v1 section 5) must replay + to the ``merkle_root`` and ``leaf_count`` of the registry *entry* (section 4), + which section 5.1 takes as its third input. A receipt that also carries its + own ``merkle_root`` or ``leaf_count`` must carry the entry's (section 6). Without a receipt or without the entry, TR-ANC-002 fails: Level 2 means anchored in a named entry, and a URI is a pointer at an anchor rather than evidence of one. Nothing yet ties the URI to the entry (trace-spec#448 @@ -69,7 +71,7 @@ def _check_inclusion( ) try: - leaf_index, audit_path, leaf_count, merkle_root = parse_receipt(receipt) + leaf_index, audit_path, leaf_count, merkle_root = parse_proof(receipt) except InclusionError as exc: return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: malformed anchor receipt: {exc}") @@ -86,7 +88,7 @@ def _check_inclusion( except InclusionError as exc: return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: malformed registry entry: {exc}") - if merkle_root != entry_root: + if merkle_root is not None and merkle_root != entry_root: return Finding( "TR-ANC-002", Status.FAIL, f"TR-ANC-002: receipt merkle_root {merkle_root.hex()[:16]}... does not equal " @@ -94,7 +96,7 @@ def _check_inclusion( f"({entry_root.hex()[:16]}...). The receipt does not describe that entry.", ) - if leaf_count != entry_count: + if leaf_count is not None and leaf_count != entry_count: return Finding( "TR-ANC-002", Status.FAIL, f"TR-ANC-002: receipt leaf_count {leaf_count!r} does not equal the leaf_count " @@ -103,21 +105,21 @@ def _check_inclusion( ) try: - proven = verify_inclusion(trace, leaf_index, audit_path, leaf_count, merkle_root) + proven = verify_inclusion(trace, leaf_index, audit_path, entry_count, entry_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}) of registry entry {producer} {batch_id}; " + f"inclusion proven against merkle_root {entry_root.hex()[:16]}... " + f"(leaf {leaf_index} of {entry_count}) of registry entry {producer} {batch_id}; " "the link from the transparency URI to this entry is not checked " "(trace-spec#448 finding 21)", ) 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 " + f"(leaf {leaf_index} of {entry_count}). The record is not in the tree this " "receipt commits to, or it has been modified since it was anchored.", ) diff --git a/conformance/tests/test_tr_anc_entry_binding.py b/conformance/tests/test_tr_anc_entry_binding.py index e342facc..1c2c7c4a 100644 --- a/conformance/tests/test_tr_anc_entry_binding.py +++ b/conformance/tests/test_tr_anc_entry_binding.py @@ -208,3 +208,44 @@ def report(extra: list[str]) -> str: without = report([]) assert "no registry entry supplied" in without assert "transparency URI to this entry is not checked" not in without + + +def _proof_only(receipt: dict[str, Any]) -> dict[str, Any]: + """The section 5 proof alone: what a producer hands a claim holder.""" + return {"leaf_index": receipt["leaf_index"], "audit_path": receipt["audit_path"]} + + +def test_proof_only_receipt_anchors_against_its_entry() -> None: + claims = _claims() + receipt = _receipt(claims, 2) + finding = tr_anc._check_inclusion(claims[2], _proof_only(receipt), _entry(receipt)) + assert finding.status is Status.PASS, finding.message + + +def test_proof_only_receipt_is_refused_against_another_batch() -> None: + claims = _claims() + receipt = _receipt(claims, 2) + other = _receipt([{"transparency": "https://log.example.com/entries/other", "iat": 1}] * 3, 0) + finding = tr_anc._check_inclusion(claims[2], _proof_only(receipt), _entry(other)) + assert finding.status is Status.FAIL + assert "does not reproduce the committed merkle_root" in finding.message + + +@pytest.mark.parametrize("field", ["leaf_index", "audit_path"]) +def test_receipt_without_the_section_5_proof_is_malformed(field: str) -> None: + claims = _claims() + receipt = _receipt(claims, 1) + receipt.pop(field) + finding = tr_anc._check_inclusion(claims[1], receipt, _entry(_receipt(claims, 1))) + assert finding.status is Status.FAIL + assert f"missing required field(s): {field}" in finding.message + + +@pytest.mark.parametrize("value", [None, "3", 3.0, True]) +def test_a_receipt_carried_leaf_count_that_is_not_an_integer_is_malformed(value: object) -> None: + claims = _claims() + receipt = _receipt(claims, 1) + receipt["leaf_count"] = value + finding = tr_anc._check_inclusion(claims[1], receipt, _entry(_receipt(claims, 1))) + assert finding.status is Status.FAIL + assert "leaf_count must be an integer" in finding.message diff --git a/conformance/tests/test_tr_anc_inclusion.py b/conformance/tests/test_tr_anc_inclusion.py index 860cae64..1d5a2474 100644 --- a/conformance/tests/test_tr_anc_inclusion.py +++ b/conformance/tests/test_tr_anc_inclusion.py @@ -135,7 +135,9 @@ def test_a_forged_root_fails(): # --- malformed receipts are reported as malformed, not as "not included" ---- -@pytest.mark.parametrize("field", ["leaf_index", "audit_path", "leaf_count", "merkle_root"]) +# Only the section 5 proof is the receipt's own; merkle_root and leaf_count come from the +# entry (section 5.1), so a receipt that leaves them out is not malformed. +@pytest.mark.parametrize("field", ["leaf_index", "audit_path"]) def test_a_receipt_missing_a_field_says_which(field): claims = [_trace(i) for i in range(4)] receipt = _receipt(claims, 1)