From 688f0615aff18f805e39f4f543bff4db1f172e7e Mon Sep 17 00:00:00 2001 From: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:42:59 -0400 Subject: [PATCH 1/2] fix(anchor): take merkle_root and leaf_count from the entry Registry Anchor Format section 5 defines the inclusion proof as leaf_index and audit_path, and the section 5.1 algorithm takes merkle_root and leaf_count from the registry entry. TR-ANC-002 still required both on the receipt, so a proof-only receipt with a matching entry was refused as malformed. TR-ANC-002 now replays the proof against the entry's root and count. A receipt that carries its own copies must still carry the entry's (section 6). parse_receipt keeps its contract for callers that replay a self-contained receipt without an entry. Signed-off-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com> --- conformance/docs/modules/tr-anc.md | 2 +- conformance/src/trace_tests/inclusion.py | 37 +++++++++++++++---- conformance/src/trace_tests/modules/tr_anc.py | 24 ++++++------ .../tests/test_tr_anc_entry_binding.py | 31 ++++++++++++++++ conformance/tests/test_tr_anc_inclusion.py | 4 +- 5 files changed, 78 insertions(+), 20 deletions(-) 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..01677598 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)}") @@ -156,11 +159,31 @@ def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]: return ( receipt["leaf_index"], [decode_hash(node) for node in raw_path], - receipt["leaf_count"], - decode_hash(receipt["merkle_root"]), + receipt.get("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..c5d5403a 100644 --- a/conformance/tests/test_tr_anc_entry_binding.py +++ b/conformance/tests/test_tr_anc_entry_binding.py @@ -208,3 +208,34 @@ 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 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) From 762f7b681eb0c446a7dec714ec6eacf3098eeb09 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:11:24 -0400 Subject: [PATCH 2/2] fix(anchor): refuse a receipt leaf_count that is not an integer A receipt may leave leaf_count out and take the entry's, but one that carries the field must carry an integer. An explicit null was read as absent and skipped the comparison with the entry. Signed-off-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com> --- conformance/src/trace_tests/inclusion.py | 8 +++++++- conformance/tests/test_tr_anc_entry_binding.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/conformance/src/trace_tests/inclusion.py b/conformance/src/trace_tests/inclusion.py index 01677598..07e16352 100644 --- a/conformance/src/trace_tests/inclusion.py +++ b/conformance/src/trace_tests/inclusion.py @@ -156,10 +156,16 @@ def parse_proof(receipt: object) -> tuple[int, list[bytes], int | None, 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.get("leaf_count"), + leaf_count, decode_hash(receipt["merkle_root"]) if "merkle_root" in receipt else None, ) diff --git a/conformance/tests/test_tr_anc_entry_binding.py b/conformance/tests/test_tr_anc_entry_binding.py index c5d5403a..1c2c7c4a 100644 --- a/conformance/tests/test_tr_anc_entry_binding.py +++ b/conformance/tests/test_tr_anc_entry_binding.py @@ -239,3 +239,13 @@ def test_receipt_without_the_section_5_proof_is_malformed(field: str) -> None: 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