Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conformance/docs/modules/tr-anc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
43 changes: 36 additions & 7 deletions conformance/src/trace_tests/inclusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -143,24 +148,48 @@ 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)}")

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__}")

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")


Expand Down
24 changes: 13 additions & 11 deletions conformance/src/trace_tests/modules/tr_anc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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}")

Expand All @@ -86,15 +88,15 @@ 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 "
f"the merkle_root of registry entry {producer} {batch_id} "
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 "
Expand All @@ -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.",
)
41 changes: 41 additions & 0 deletions conformance/tests/test_tr_anc_entry_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion conformance/tests/test_tr_anc_inclusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading