diff --git a/CHANGELOG.md b/CHANGELOG.md index b1190af5..77bac794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- **Breaking, proposed: a Level 2 anchor verdict requires the registry entry (#464, + following trace-tests#92).** Registry Anchor Format §6 now says the verdict rests on + the entry itself, the third input of §5.1: a receipt's `merkle_root` and `leaf_count` + must equal the entry's or the anchor is refused, and a verifier that does not hold + the entry does not report Level 2. Before this, the conformance suite judged the + proof against a root and count the receipt supplied, so a passing TR-ANC-002 tied the + record to no entry at all. Nothing yet identifies an entry from the `transparency` + value, so a verifier states that this link was not checked; it is tracked as finding + 21 of #448. What breaks: a Level 2 result produced from a receipt alone no longer + passes. What implementers do: keep the registry entry with the record and its proof, + as the anchoring tutorial already says, and pass it with `--entry`. Records, the + receipt shape and the entry shape do not change. Classified breaking under + GOVERNANCE.md as a conformance level redefinition, so adoption needs the 30-day + comment period and Project Lead sign-off. Reported by @solloek369-arch. + - **Section 3.1.5: a layer is established by its event log or by a reference value (#279).** As merged, a layer was `established` only when its event log replayed to the quoted value, so a quote that equals signed reference values with no log could not be diff --git a/conformance/.clusterfuzzlite/fuzz_receipt.py b/conformance/.clusterfuzzlite/fuzz_receipt.py index a918e2ae..2e9573ce 100755 --- a/conformance/.clusterfuzzlite/fuzz_receipt.py +++ b/conformance/.clusterfuzzlite/fuzz_receipt.py @@ -7,7 +7,7 @@ * ``tr_anc.check`` returns findings for any receipt JSON; ``parse_receipt`` and ``verify_inclusion`` raise nothing but ``InclusionError``. * TR-ANC-002 never passes unless the receipt commits to the genuine Merkle - root and the claim is the one that root includes. Forging either takes a + root of the supplied entry and the claim is the one that root includes. Forging either takes a SHA-256 collision, so a PASS on anything else is a defect in the replay. Half the inputs are raw receipt bytes; the other half start from a genuine @@ -67,6 +67,14 @@ def _genuine() -> tuple[dict, bytes]: _RECEIPT, _ROOT = _genuine() +# The registry entry sealed with the genuine receipt (registry-anchor-v1 section 4). +_ENTRY = { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": _RECEIPT["merkle_root"], + "leaf_count": _RECEIPT["leaf_count"], + "producer": "fuzz/1.0.0", + "batch_id": "fuzz-1", +} if not verify_inclusion(_CLAIMS[_TARGET], *parse_receipt(_RECEIPT)): raise SystemExit("fuzz_receipt: the genuine receipt does not verify; the harness is wrong") @@ -110,7 +118,7 @@ def TestOneInput(data: bytes) -> None: if not isinstance(receipt, dict): return - findings = tr_anc.check(claim, receipt=receipt) + findings = tr_anc.check(claim, receipt=receipt, entry=_ENTRY) if any(f.code == "TR-ANC-002" and f.passed() for f in findings): assert claim is _CLAIMS[_TARGET], f"inclusion proven for the wrong claim: {receipt!r}" root = parse_receipt(receipt)[3] diff --git a/conformance/.clusterfuzzlite/fuzz_record.py b/conformance/.clusterfuzzlite/fuzz_record.py index 755394b7..723d9720 100755 --- a/conformance/.clusterfuzzlite/fuzz_record.py +++ b/conformance/.clusterfuzzlite/fuzz_record.py @@ -25,6 +25,14 @@ "audit_path": [], "merkle_root": "sha256:" + "0" * 64, } +# Matches the receipt, so TR-ANC-002 reaches the leaf canonicalization on every record. +_ENTRY = { + "ts": "1970-01-01T00:00:00Z", + "merkle_root": _RECEIPT["merkle_root"], + "leaf_count": 1, + "producer": "fuzz/1.0.0", + "batch_id": "fuzz-1", +} def _resolver(uri: str) -> bytes: @@ -46,6 +54,7 @@ def TestOneInput(data: bytes) -> None: level, expected_nonce="fuzz-nonce", receipt=_RECEIPT, + entry=_ENTRY, policy_resolver=_resolver, ) @@ -55,6 +64,7 @@ def TestOneInput(data: bytes) -> None: (0, 1, 2), expected_nonce="fuzz-nonce", receipt=_RECEIPT, + entry=_ENTRY, policy_resolver=_resolver, ) built = report._build_from_execution( diff --git a/conformance/CHANGELOG.md b/conformance/CHANGELOG.md index c3b49e75..533a800a 100644 --- a/conformance/CHANGELOG.md +++ b/conformance/CHANGELOG.md @@ -4,6 +4,19 @@ ### Changed +- **Breaking: TR-ANC-002 requires the registry entry (trace-spec#464).** `--entry` + takes one Anchor Format §4 line, and `run()` and `tr_anc.check()` take `entry`. + The receipt's `merkle_root` and `leaf_count` must equal the entry's; with no entry, + TR-ANC-002 fails as it does with no receipt. The pass message names the entry and + says the link from the `transparency` URI to it is not checked (trace-spec#448 + finding 21). Tests cover the four worlds from trace-tests#92 and the absent entry. +- **Breaking: TR-ANC-002 proves inclusion of the complete signed claim.** For + `cmcp-runtime` the runner now gives TR-ANC the whole envelope, signature included, + as Anchor Format §1 and the reference registry already define the leaf; before, it + proved the nested `trace`, so a registry-valid anchor failed and a re-signed + envelope with changed gateway fields still passed. `tr_anc.check()` takes + `subject`. The entry `ts` must be an ISO-8601 UTC timestamp ending in `Z`. + Reported on trace-spec#533 by solloek369-arch and reproduced by dinakarjs. - Vendor-neutral placeholders (`example-provider`, `example-model`) in the unsigned vectors and the policy-resolution generator; the policy-resolution set is regenerated and every verdict is unchanged. Signed vectors are untouched. diff --git a/conformance/LIMITATIONS.md b/conformance/LIMITATIONS.md index 7f8bff4f..3427fdf3 100644 --- a/conformance/LIMITATIONS.md +++ b/conformance/LIMITATIONS.md @@ -29,18 +29,22 @@ against AMD, Intel or a TPM manufacturer root. A record can satisfy `TR-RTE` at syntactically perfect measurement that no hardware ever produced. Verifying the quote against the silicon vendor is the relying party's job, and `cmcp_verify` is where that happens. -**`TR-ANC-002` proves inclusion relative to the receipt you hand it.** -In plain terms: it proves the record is in the log the receipt describes, not that the log is a -real public one. -It replays the audit path against the Merkle root carried in that receipt. It does not fetch the -`transparency` URI, and it does not establish that the root is one a public log actually -published. A self-consistent receipt over a tree the submitter built themselves will pass. What -the check rules out is a record that has been modified since anchoring, or that was never in the -tree the receipt commits to. Establishing that the tree is real is a separate step and is not in -scope here. +**`TR-ANC-002` proves inclusion relative to the receipt and entry you hand it.** +In plain terms: it proves the record is in the registry entry you supplied, not that the entry is +the one the record names, nor that the log is a real public one. +It requires the receipt's `merkle_root` and `leaf_count` to equal the supplied entry's, and +replays the audit path against that root. It does not fetch the `transparency` URI. Nothing in +the Anchor Format yet identifies an entry from that URI, so the check cannot establish that the +supplied entry is the one the record names; the pass message says so, and closing that link is +tracked as finding 21 of trace-spec#448. It also does not establish that a public log actually +published the entry. A self-consistent receipt and entry over a tree the submitter built +themselves will pass. What the check rules out is a record that has been modified since +anchoring, a record that was never in the tree, and a receipt whose root or count differs from +the entry it is presented with. Establishing that the entry is real and is the named one is a +separate step and is not in scope here. `TR-ANC-001` is explicit that it checks the pointer rather than the anchor. Supplying no receipt -fails `TR-ANC-002`, so Level 2 cannot be reached on a well formed URI alone. +or no entry fails `TR-ANC-002`, so Level 2 cannot be reached on a well formed URI alone. ## Statuses that are easy to misread diff --git a/conformance/docs/error-codes.md b/conformance/docs/error-codes.md index d60bef6d..9cc155d5 100644 --- a/conformance/docs/error-codes.md +++ b/conformance/docs/error-codes.md @@ -60,7 +60,7 @@ Look up a code from a failed or unverified check here: what it means and how to | Code | Description | How to fix | |------|-------------|------------| | TR-ANC-001 | `transparency` is absent or empty, is not a string, or is not an `https://` URI with a host | Submit the record to a SCITT transparency log and set `transparency` to the returned receipt URI. The URI is not resolved and the receipt behind it is not fetched; this is a format check on the pointer, and TR-ANC-002 is what checks the anchor | -| TR-ANC-002 | No anchor receipt was supplied, the receipt is malformed, or replaying its inclusion proof does not reproduce the committed `merkle_root` | Pass the receipt with `--receipt`. Without one, nothing proves the record is in the log the URI names, so Level 2 cannot pass. If a receipt is supplied and the proof does not verify, the record is not in that tree or it has been modified since it was anchored | +| TR-ANC-002 | No anchor receipt or no registry entry was supplied, either is malformed, the receipt's `merkle_root` or `leaf_count` differs from the entry's, or replaying the inclusion proof does not reproduce the committed `merkle_root` | Pass the receipt with `--receipt` and the registry entry it commits to with `--entry`. Without both, Level 2 cannot pass. A receipt whose root or count differs from the entry's does not describe that entry. If the proof does not verify, the record is not in that tree or it has been modified since it was anchored. A pass does not check that the entry is the one the `transparency` URI names | ## TR-SCA: Provenance diff --git a/conformance/docs/levels.md b/conformance/docs/levels.md index 23e2bc51..016a8473 100644 --- a/conformance/docs/levels.md +++ b/conformance/docs/levels.md @@ -155,7 +155,7 @@ The `transparency` field must be an HTTPS URI with a host, pointing to a SCITT r - `tool_transcript.hash` missing or not a valid `sha256:` or `sha384:` digest: TR-TXN-001 - `tool_transcript.call_count` negative or not an integer: TR-TXN-002 - `transparency` is absent or empty, is not a string, or is not an `https://` URI with a host: TR-ANC-001 -- no anchor receipt was supplied, the receipt is malformed, or its inclusion proof does not reproduce the committed `merkle_root`: TR-ANC-002. A record cannot reach Level 2 on a URI alone: pass the receipt with `--receipt` +- no anchor receipt or no registry entry was supplied, either is malformed, the receipt's `merkle_root` or `leaf_count` differs from the entry's, or the inclusion proof does not reproduce the committed `merkle_root`: TR-ANC-002. A record cannot reach Level 2 on a URI alone: pass the receipt with `--receipt` and the entry with `--entry` - `policy.policy_uri` is present, a resolver was supplied with `--policy-dir`, and the bundle could not be read: TR-POL-003. Unverified rather than failed, and unverified fails the run from Level 2 --- diff --git a/conformance/docs/modules.md b/conformance/docs/modules.md index 0848f65d..bd25b194 100644 --- a/conformance/docs/modules.md +++ b/conformance/docs/modules.md @@ -12,5 +12,5 @@ The table below uses the record's own field names (in code font). The [conforman | [Policy](modules/tr-pol.md) | TR-POL | §3.1 | Policy bundle hash format, enforcement mode values, and whether the bundle at `policy_uri` has the declared digest | | [Appraisal](modules/tr-apr.md) | TR-APR | §3.1 | Appraisal status enum, verifier URI form, `policy_ref` URI form, and timestamp plausibility. The appraisal policy is never resolved | | [Transcript](modules/tr-txn.md) | TR-TXN | §3.1 | Tool-call transcript hash format (`sha256:` or `sha384:`) and call count | -| [Transparency](modules/tr-anc.md) | TR-ANC | §3.2 | SCITT receipt URI form (TR-ANC-001), and offline replay of the inclusion proof against the committed Merkle root when a receipt is supplied (TR-ANC-002). The URI itself is never resolved | +| [Transparency](modules/tr-anc.md) | TR-ANC | §3.2 | SCITT receipt URI form (TR-ANC-001), and offline replay of the inclusion proof against the supplied registry entry, whose root and count the receipt must match (TR-ANC-002). The URI itself is never resolved, and the URI-to-entry link is not checked | | [Provenance](modules/tr-sca.md) | TR-SCA | §3.1 | SLSA provenance level and digest format (`sha256:` or `sha384:`) | diff --git a/conformance/docs/modules/tr-anc.md b/conformance/docs/modules/tr-anc.md index d0de06ad..a4a9363f 100644 --- a/conformance/docs/modules/tr-anc.md +++ b/conformance/docs/modules/tr-anc.md @@ -1,17 +1,21 @@ # TR-ANC: Transparency -Checks that the record was entered in a public, append-only log, so it cannot be quietly replaced later. TR-ANC-001 checks the link to the log entry. TR-ANC-002 uses a receipt you supply to prove, offline, that this exact record is in the log the receipt describes. Both apply from Level 2. +Checks that the record was entered in a public, append-only log, so it cannot be quietly replaced later. TR-ANC-001 checks the link to the log entry. TR-ANC-002 uses a receipt and the registry entry you supply to prove, offline, that this exact record is in that entry. Both apply from Level 2. ??? info "Technical detail: what the receipt proof covers" The log follows SCITT, the IETF format for transparency services. The receipt carries an inclusion proof (a short list of hashes linking the record to a Merkle root, the single - hash that commits to the whole log). The proof is checked against the root in that - receipt; whether a public log really published that root is outside this module, as - [Known limitations](../../LIMITATIONS.md) explains. + hash that commits to the whole log). The receipt's `merkle_root` and `leaf_count` must + equal those of the registry entry you pass with `--entry` (one line in the shape of + Anchor Format §4), and the proof is then replayed against that root. Nothing yet + identifies an entry from the `transparency` URI, so the check does not establish that + the entry is the one the record names, and the pass message says so (trace-spec#448 + finding 21). Whether a public log really published the entry is also outside this + module, as [Known limitations](../../LIMITATIONS.md) explains. ## Required at Level 2+ | 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 record's inclusion proof replays to the committed `merkle_root`, per RFC 9162 over an RFC 6962 tree. Offline, no network | a receipt whose `audit_path` reproduces `merkle_root` from the record's leaf | no receipt supplied, 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` from the leaf of the complete signed claim, signature included (Anchor Format §1; for `cmcp-runtime` that is the whole envelope, not the nested `trace`), per RFC 9162 over an RFC 6962 tree; any `merkle_root` or `leaf_count` the receipt carries equals the entry's, and the entry's `ts` is an ISO-8601 UTC timestamp ending in `Z`. 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 or `ts`, 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, a `cmcp-runtime` proof over the nested `trace` only, an envelope re-signed or with a changed signature after anchoring | diff --git a/conformance/src/trace_tests/cli.py b/conformance/src/trace_tests/cli.py index 55d3f8fe..7ed4f2f7 100644 --- a/conformance/src/trace_tests/cli.py +++ b/conformance/src/trace_tests/cli.py @@ -82,28 +82,38 @@ def _print_report(path: str, fmt: str, level: int, results: dict[str, list[Any]] -def _load_receipt(path: str | None) -> dict | None: - """Load and shape-check an anchor receipt, or return None when not supplied.""" +def _load_json_object(path: str | None, label: str) -> dict | None: + """Load and shape-check a JSON object input, or return None when not supplied.""" if path is None: return None try: with open(path, "rb") as fh: data = loads_strict(fh.read()) except OSError as exc: - click.echo(f"Error: cannot read receipt {path}: {exc}", err=True) + click.echo(f"Error: cannot read {label} {path}: {exc}", err=True) sys.exit(2) except LoadError as exc: - click.echo(f"Error: receipt {path} is not valid JSON: {exc}", err=True) + click.echo(f"Error: {label} {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__}", + f"Error: {label} {path} must be a JSON object, got {type(data).__name__}", err=True, ) sys.exit(2) return data +def _load_receipt(path: str | None) -> dict | None: + """Load and shape-check an anchor receipt, or return None when not supplied.""" + return _load_json_object(path, "receipt") + + +def _load_entry(path: str | None) -> dict | None: + """Load and shape-check a registry entry, or return None when not supplied.""" + return _load_json_object(path, "entry") + + def _load_policy_resolver(policy_dir: str | None) -> Callable[[str], bytes] | None: """Build a policy-bundle resolver from DIR/resolutions.json, or None when not supplied. @@ -206,6 +216,17 @@ def main() -> None: "says where the anchor lives, the receipt is what proves the record is in it." ), ) +@click.option( + "--entry", + default=None, + type=click.Path(), + help=( + "Path to the registry entry (JSON, one registry-anchor-v1 section 4 line) the " + "receipt commits to. Required for TR-ANC-002 at Level 2: the receipt's " + "merkle_root and leaf_count must equal the entry's. The link from the " + "transparency URI to this entry is not checked." + ), +) @click.option( "--policy-dir", "policy_dir", @@ -223,6 +244,7 @@ def verify( max_age: int, expected_nonce: str | None, receipt: str | None, + entry: str | None, policy_dir: str | None, ) -> None: """Verify a TRACE trust record against the conformance suite.""" @@ -233,6 +255,7 @@ def verify( sys.exit(2) receipt_data = _load_receipt(receipt) + entry_data = _load_entry(entry) policy_resolver = _load_policy_resolver(policy_dir) results = run( @@ -242,6 +265,7 @@ def verify( max_age_seconds=max_age, expected_nonce=expected_nonce, receipt=receipt_data, + entry=entry_data, policy_resolver=policy_resolver, ) exit_code = _print_report(record, fmt, level, results) @@ -289,6 +313,13 @@ def verify( type=click.Path(), help="Path to the anchor receipt (JSON). Required for TR-ANC-002 at Level 2.", ) +@click.option( + "--entry", + default=None, + type=click.Path(), + help="Path to the registry entry (JSON) the receipt commits to. Required for " + "TR-ANC-002 at Level 2.", +) @click.option( "--policy-dir", "policy_dir", @@ -308,6 +339,7 @@ def report( fail_under: int | None, expected_nonce: str | None, receipt: str | None, + entry: str | None, policy_dir: str | None, ) -> None: """Produce a conformance report you can hand to someone else. @@ -323,6 +355,7 @@ def report( sys.exit(2) receipt_data = _load_receipt(receipt) + entry_data = _load_entry(entry) policy_resolver = _load_policy_resolver(policy_dir) execution = _run_levels( @@ -332,6 +365,7 @@ def report( max_age_seconds=max_age, expected_nonce=expected_nonce, receipt=receipt_data, + entry=entry_data, policy_resolver=policy_resolver, ) diff --git a/conformance/src/trace_tests/inclusion.py b/conformance/src/trace_tests/inclusion.py index ff6303e2..bbd9e6d3 100644 --- a/conformance/src/trace_tests/inclusion.py +++ b/conformance/src/trace_tests/inclusion.py @@ -18,13 +18,20 @@ import hashlib import json import re +from datetime import datetime from typing import Any LEAF_PREFIX = b"\x00" NODE_PREFIX = b"\x01" _HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") # fullmatch: $ admits a trailing newline -__all__ = ["InclusionError", "canonical_claim_bytes", "decode_hash", "verify_inclusion"] +__all__ = [ + "InclusionError", + "canonical_claim_bytes", + "decode_hash", + "parse_entry", + "verify_inclusion", +] class InclusionError(ValueError): @@ -127,8 +134,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 @@ -137,9 +149,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)}") @@ -147,9 +157,85 @@ 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") +_UTC_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z") + + +def _is_utc_timestamp(value: str) -> bool: + """True iff *value* is a real ISO-8601 UTC date-time with a Z suffix (section 4).""" + if not _UTC_TIMESTAMP.fullmatch(value): + return False + try: + datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S") + except ValueError: + return False + return True + + +def parse_entry(entry: object) -> tuple[bytes, int, str, str]: + """Validate a registry entry and return (merkle_root, leaf_count, producer, batch_id). + + The entry is the registry-anchor-v1 section 4 line the receipt claims to + commit to. Fields beyond section 4 are ignored rather than rejected, because + the reference registry already writes some (trace-spec#448 item 20). + """ + if not isinstance(entry, dict): + raise InclusionError(f"registry entry must be a JSON object, got {type(entry).__name__}") + + missing = [ + k for k in ("ts", "merkle_root", "leaf_count", "producer", "batch_id") if k not in entry + ] + if missing: + raise InclusionError(f"registry entry is missing required field(s): {', '.join(missing)}") + + for key in _ENTRY_STRINGS: + if not isinstance(entry[key], str) or not entry[key]: + raise InclusionError(f"registry entry {key} must be a non-empty string") + + if not _is_utc_timestamp(entry["ts"]): + raise InclusionError( + "registry entry ts must be an ISO-8601 UTC timestamp ending in Z, " + f"got {entry['ts'][:40]!r}" + ) + + leaf_count = entry["leaf_count"] + if not isinstance(leaf_count, int) or isinstance(leaf_count, bool) or leaf_count < 1: + raise InclusionError( + f"registry entry leaf_count must be an integer >= 1, got {leaf_count!r}" + ) + + return decode_hash(entry["merkle_root"]), leaf_count, entry["producer"], entry["batch_id"] diff --git a/conformance/src/trace_tests/modules/tr_anc.py b/conformance/src/trace_tests/modules/tr_anc.py index fb67aaa5..58b4c196 100644 --- a/conformance/src/trace_tests/modules/tr_anc.py +++ b/conformance/src/trace_tests/modules/tr_anc.py @@ -5,18 +5,34 @@ from typing import Any from urllib.parse import urlparse -from trace_tests.inclusion import InclusionError, parse_receipt, verify_inclusion +from trace_tests.inclusion import InclusionError, parse_entry, parse_proof, verify_inclusion from trace_tests.result import Finding, Status -def check(trace: dict[str, Any], receipt: dict[str, Any] | None = None) -> list[Finding]: +def check( + trace: dict[str, Any], + receipt: dict[str, Any] | None = None, + entry: dict[str, Any] | None = None, + subject: dict[str, Any] | None = None, +) -> list[Finding]: """Return TR-ANC findings for the transparency claim. + *subject* is the object the anchor leaf commits to: the complete signed + claim, signature included (registry-anchor-v1 section 1). For an enveloping + format such as ``cmcp-runtime`` that is the whole envelope, not the nested + *trace*; the runner passes it. It defaults to *trace*, which is the complete + claim for a bare TRACE record. + 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. + 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 + finding 21), and the TR-ANC-002 message says so. """ findings: list[Finding] = [] transparency = trace.get("transparency") @@ -43,12 +59,16 @@ def check(trace: dict[str, Any], receipt: dict[str, Any] | None = None) -> list[ 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)) + findings.append(_check_inclusion(trace if subject is None else subject, receipt, entry)) 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.""" +def _check_inclusion( + subject: dict[str, Any], + receipt: dict[str, Any] | None, + entry: dict[str, Any] | None, +) -> Finding: + """Bind the receipt to the entry and replay the proof, or say why not.""" if receipt is None: return Finding( "TR-ANC-002", Status.FAIL, @@ -58,24 +78,56 @@ def _check_inclusion(trace: dict[str, Any], receipt: dict[str, Any] | None) -> F ) 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}") + if entry is None: + return Finding( + "TR-ANC-002", Status.FAIL, + "TR-ANC-002: no registry entry supplied, so the receipt's merkle_root and " + "leaf_count were not shown to belong to any entry and Level 2 is not earned. " + "Pass the registry-anchor-v1 section 4 entry with --entry.", + ) + + try: + entry_root, entry_count, producer, batch_id = parse_entry(entry) + except InclusionError as exc: + return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: malformed registry entry: {exc}") + + 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 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 " + f"of registry entry {producer} {batch_id} ({entry_count}). The receipt does " + "not describe that entry.", + ) + try: - proven = verify_inclusion(trace, leaf_index, audit_path, leaf_count, merkle_root) + proven = verify_inclusion(subject, 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})", + f"inclusion proven against merkle_root {entry_root.hex()[:16]}... " + f"(leaf {leaf_index} of {entry_count}) of registry entry {producer} {batch_id}, " + "over the complete signed claim; 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/src/trace_tests/runner.py b/conformance/src/trace_tests/runner.py index b5f41475..40ebaf03 100644 --- a/conformance/src/trace_tests/runner.py +++ b/conformance/src/trace_tests/runner.py @@ -62,6 +62,7 @@ def _run_core( max_age_seconds: int, expected_nonce: str | None, receipt: dict[str, Any] | None, + entry: dict[str, Any] | None, policy_resolver: Callable[[str], bytes] | None, ) -> dict[str, list[Finding]]: """Run one predecoded level through the shared public/accounting path.""" @@ -100,7 +101,7 @@ def _run_core( if "TR-ANC" in active: results["TR-ANC"] = invoke( - "TR-ANC", lambda: anc(trace, receipt=receipt) + "TR-ANC", lambda: anc(trace, receipt=receipt, entry=entry, subject=record) ) return results @@ -113,6 +114,7 @@ def run( max_age_seconds: int = tr_env.DEFAULT_MAX_AGE_SECONDS, expected_nonce: str | None = None, receipt: dict[str, Any] | None = None, + entry: dict[str, Any] | None = None, policy_resolver: Callable[[str], bytes] | None = None, ) -> dict[str, list[Finding]]: """Run all modules required for *level* and return findings keyed by module ID.""" @@ -131,6 +133,7 @@ def run( max_age_seconds=max_age_seconds, expected_nonce=expected_nonce, receipt=receipt, + entry=entry, policy_resolver=policy_resolver, ) @@ -142,6 +145,7 @@ def _run_levels( max_age_seconds: int = tr_env.DEFAULT_MAX_AGE_SECONDS, expected_nonce: str | None = None, receipt: dict[str, Any] | None = None, + entry: dict[str, Any] | None = None, policy_resolver: Callable[[str], bytes] | None = None, ) -> accounting._Execution: """Run one exact contiguous level set and return its atomic accounting value.""" @@ -188,6 +192,7 @@ def _run_levels( max_age_seconds=max_age_seconds, expected_nonce=expected_nonce, receipt=receipt, + entry=entry, policy_resolver=policy_resolver, ) end(level) diff --git a/conformance/tests/test_anchor_vectors.py b/conformance/tests/test_anchor_vectors.py index 6c04d2e8..c312f8f3 100644 --- a/conformance/tests/test_anchor_vectors.py +++ b/conformance/tests/test_anchor_vectors.py @@ -14,12 +14,34 @@ VECTORS = [json.loads(p.read_text()) for p in sorted(VECTOR_DIR.glob("[0-9][0-9]-*.json"))] +def _entry_matching(receipt): + """The registry entry whose root and count the receipt carries, or None. + + These vectors measure inclusion only, so the entry agrees with the receipt by + construction and TR-ANC-002 turns on the proof replay. Entry binding has its + own worlds in test_tr_anc_entry_binding.py. + """ + if not isinstance(receipt, dict) or not {"merkle_root", "leaf_count"} <= receipt.keys(): + return None + return { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": receipt["merkle_root"], + "leaf_count": receipt["leaf_count"], + "producer": "anchor-vectors/1.0.0", + "batch_id": "anchor-vectors", + } + + @pytest.mark.level2 @pytest.mark.parametrize("vector", VECTORS, ids=lambda v: v["name"]) def test_portable_inclusion_vector(vector): finding = next( f - for f in tr_anc.check(vector["claim"], receipt=vector["receipt"]) + for f in tr_anc.check( + vector["claim"], + receipt=vector["receipt"], + entry=_entry_matching(vector["receipt"]), + ) if f.code == "TR-ANC-002" ) assert finding.status.value == vector["expected"]["tr_anc_002"] diff --git a/conformance/tests/test_modules_never_raise.py b/conformance/tests/test_modules_never_raise.py index f7e506de..0cdd84f2 100644 --- a/conformance/tests/test_modules_never_raise.py +++ b/conformance/tests/test_modules_never_raise.py @@ -315,6 +315,13 @@ def test_the_cmcp_path_does_not_raise_on_a_malformed_envelope() -> None: "leaf_count": 1, "merkle_root": "sha256:" + "0" * 64, } +_ENTRY: dict[str, Any] = { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": "sha256:" + "0" * 64, + "leaf_count": 1, + "producer": "never-raise/1.0.0", + "batch_id": "never-raise-1", +} def _nested(depth: int = 100_000) -> list[Any]: @@ -335,7 +342,7 @@ def test_tr_anc_with_receipt_never_raises_on_malformed_or_deep_records() -> None if not label.startswith("transparency"): candidate["transparency"] = "https://registry.example.org/claim/1" try: - findings = tr_anc.check(candidate, receipt=dict(_RECEIPT)) + findings = tr_anc.check(candidate, receipt=dict(_RECEIPT), entry=dict(_ENTRY)) except Exception as exc: # noqa: BLE001 - the point is that nothing escapes raised.append(f"{label} -> {type(exc).__name__}: {exc}") continue @@ -360,7 +367,7 @@ def test_tr_anc_with_receipt_never_raises_on_malformed_or_deep_records() -> None }, } try: - findings = tr_anc.check(trace, receipt=dict(_RECEIPT)) + findings = tr_anc.check(trace, receipt=dict(_RECEIPT), entry=dict(_ENTRY)) except Exception as exc: # noqa: BLE001 - the point is that nothing escapes raised.append(f"{label} -> {type(exc).__name__}: {exc}") continue diff --git a/conformance/tests/test_tr_anc_entry_binding.py b/conformance/tests/test_tr_anc_entry_binding.py new file mode 100644 index 00000000..1c2c7c4a --- /dev/null +++ b/conformance/tests/test_tr_anc_entry_binding.py @@ -0,0 +1,251 @@ +"""TR-ANC-002 binds the receipt to the registry entry (trace-spec#464). + +Before this, the receipt supplied both the proof and the root and count it was +judged against, so nothing tied a passing proof to any registry entry. These are +the four worlds from the original report (trace-tests#92), plus the entry left +out. The receipt's ``merkle_root`` and ``leaf_count`` must equal the supplied +entry's; with no entry, Level 2 is not earned. + +What is still not checked: that the entry is the one the record's +``transparency`` URI names. Nothing in the Anchor Format identifies an entry +from the URI yet (trace-spec#448 item 21), and the PASS message says so. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from trace_tests.cli import main +from trace_tests.inclusion import canonical_claim_bytes +from trace_tests.modules import tr_anc +from trace_tests.result import Status +from trace_tests.runner import run + +LEAF = b"\x00" +NODE = b"\x01" +VECTORS = Path(__file__).parent / "vectors" + + +def _leaf(claim: dict[str, Any]) -> bytes: + return hashlib.sha256(LEAF + canonical_claim_bytes(claim)).digest() + + +def _receipt(claims: list[dict[str, Any]], index: int) -> dict[str, Any]: + """An RFC 6962 receipt for claims[index], with promoted odd nodes.""" + level = [_leaf(c) for c in claims] + path, idx = [], index + while len(level) > 1: + sib = idx ^ 1 + if sib < len(level): + path.append("sha256:" + level[sib].hex()) + nxt = [ + hashlib.sha256(NODE + level[i] + level[i + 1]).digest() + for i in range(0, len(level) - 1, 2) + ] + if len(level) % 2: + nxt.append(level[-1]) + level, idx = nxt, idx // 2 + return { + "leaf_index": index, + "leaf_count": len(claims), + "audit_path": path, + "merkle_root": "sha256:" + level[0].hex(), + } + + +def _entry(receipt: dict[str, Any]) -> dict[str, Any]: + """The registry-anchor-v1 section 4 line sealed with the receipt.""" + return { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": receipt["merkle_root"], + "leaf_count": receipt["leaf_count"], + "producer": "binding-test/1.0.0", + "batch_id": "binding-1", + } + + +def _claims() -> list[dict[str, Any]]: + return [ + {"transparency": "https://log.example.com/entries/binding-1", "iat": 1000 + i, + "subject": f"agent-{i}"} + for i in range(5) + ] + + +def _anc002(trace, receipt, entry): + return next(f for f in tr_anc.check(trace, receipt=receipt, entry=entry) + if f.code == "TR-ANC-002") + + +_OTHER_ROOT = "sha256:" + "ab" * 32 + + +def _world(name: str) -> tuple[dict, dict, dict | None]: + claims = _claims() + receipt = _receipt(claims, 3) + entry = _entry(receipt) + if name == "wrong-caller-root": + receipt["merkle_root"] = _OTHER_ROOT + elif name == "paired-entry-root-changed": + entry["merkle_root"] = _OTHER_ROOT + elif name == "paired-entry-count-changed": + entry["leaf_count"] = 6 + elif name == "entry-absent": + entry = None + return claims[3], receipt, entry + + +@pytest.mark.parametrize( + ("world", "expected"), + [ + ("matching-sealed-entry", Status.PASS), + ("wrong-caller-root", Status.FAIL), + ("paired-entry-root-changed", Status.FAIL), + ("paired-entry-count-changed", Status.FAIL), + ("entry-absent", Status.FAIL), + ], +) +def test_the_four_worlds_and_the_absent_entry(world: str, expected: Status) -> None: + assert _anc002(*_world(world)).status is expected + + +def test_each_refusal_names_its_reason() -> None: + assert "merkle_root" in _anc002(*_world("wrong-caller-root")).message + assert "binding-1" in _anc002(*_world("paired-entry-root-changed")).message + assert "leaf_count" in _anc002(*_world("paired-entry-count-changed")).message + absent = _anc002(*_world("entry-absent")).message + assert "no registry entry supplied" in absent and "Level 2 is not earned" in absent + + +def test_the_pass_states_the_link_it_does_not_check() -> None: + message = _anc002(*_world("matching-sealed-entry")).message + assert "binding-test/1.0.0 binding-1" in message + assert "transparency URI to this entry is not checked" in message + + +def test_the_entry_does_not_rescue_a_record_outside_the_tree() -> None: + claims = _claims() + receipt = _receipt(claims, 3) + tampered = dict(claims[3], subject="agent-elsewhere") + assert _anc002(tampered, receipt, _entry(receipt)).status is Status.FAIL + + +@pytest.mark.parametrize( + ("mutate", "reason"), + [ + (lambda e: e.pop("batch_id"), "missing required field(s): batch_id"), + (lambda e: e.pop("ts"), "missing required field(s): ts"), + (lambda e: e.update(producer=""), "producer must be a non-empty string"), + (lambda e: e.update(leaf_count=True), "leaf_count must be an integer"), + (lambda e: e.update(leaf_count=0), "leaf_count must be an integer"), + (lambda e: e.update(merkle_root="sha256:XYZ"), "malformed hash value"), + ], + ids=["no-batch-id", "no-ts", "empty-producer", "bool-count", "zero-count", "bad-root"], +) +def test_a_malformed_entry_is_reported_as_malformed(mutate, reason: str) -> None: + trace, receipt, entry = _world("matching-sealed-entry") + mutate(entry) + finding = _anc002(trace, receipt, entry) + assert finding.status is Status.FAIL + assert "malformed registry entry" in finding.message and reason in finding.message + + +def test_a_non_object_entry_is_malformed() -> None: + trace, receipt, _ = _world("matching-sealed-entry") + assert "malformed registry entry" in _anc002(trace, receipt, ["not", "an", "entry"]).message + + +def test_fields_beyond_section_4_are_ignored() -> None: + trace, receipt, entry = _world("matching-sealed-entry") + entry["canonicalization_id"] = "sorted-key" + assert _anc002(trace, receipt, entry).status is Status.PASS + + +# --- the entry reaches TR-ANC through the runner and the CLI ---------------- + +def _anchored_level0(load_vector) -> tuple[dict, dict, dict]: + record = copy.deepcopy(load_vector("valid_level0.json")) + record["transparency"] = "https://log.example.com/entries/binding-1" + receipt = _receipt([record], 0) + return record, receipt, _entry(receipt) + + +def _runner_anc002(record, receipt, entry) -> Status: + results = run(record, "trace", 2, receipt=receipt, entry=entry) + return next(f for f in results["TR-ANC"] if f.code == "TR-ANC-002").status + + +def test_the_runner_passes_the_entry_through(load_vector) -> None: + record, receipt, entry = _anchored_level0(load_vector) + assert _runner_anc002(record, receipt, entry) is Status.PASS + assert _runner_anc002(record, receipt, None) is Status.FAIL + + +def test_the_cli_reads_entry(load_vector, tmp_path: Path) -> None: + record, receipt, entry = _anchored_level0(load_vector) + paths = {} + for name, value in (("record", record), ("receipt", receipt), ("entry", entry)): + paths[name] = tmp_path / f"{name}.json" + paths[name].write_text(json.dumps(value), encoding="utf-8") + args = ["report", "--record", str(paths["record"]), "--receipt", str(paths["receipt"]), + "--json", str(tmp_path / "out.json")] + + def report(extra: list[str]) -> str: + result = CliRunner().invoke(main, args + extra) + assert result.exit_code == 0, result.output + return (tmp_path / "out.json").read_text(encoding="utf-8") + + with_entry = report(["--entry", str(paths["entry"])]) + assert "transparency URI to this entry is not checked" in with_entry + assert "no registry entry supplied" not in with_entry + 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 3223736d..1d5a2474 100644 --- a/conformance/tests/test_tr_anc_inclusion.py +++ b/conformance/tests/test_tr_anc_inclusion.py @@ -64,6 +64,17 @@ def _receipt(claims: list[dict], index: int) -> dict: } +def _entry(receipt: dict) -> dict: + """The registry entry a receipt commits to, as registry-anchor-v1 section 4 lays it out.""" + return { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": receipt["merkle_root"], + "leaf_count": receipt["leaf_count"], + "producer": "inclusion-test/1.0.0", + "batch_id": "inclusion-1", + } + + def _trace(n: int = 0) -> dict: return {"transparency": "https://log.example.com/entries/1", "iat": 1000 + n, "subject": f"agent-{n}"} @@ -93,13 +104,15 @@ def test_the_pass_message_no_longer_overstates_what_was_checked(): @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))) + receipt = _receipt(claims, index) + codes = _codes(tr_anc.check(claims[index], receipt=receipt, entry=_entry(receipt))) 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))) + receipt = _receipt(claims, 1) + codes = _codes(tr_anc.check(claims[0], receipt=receipt, entry=_entry(receipt))) assert codes["TR-ANC-002"] is Status.FAIL @@ -108,19 +121,23 @@ def test_a_record_modified_after_anchoring_fails(): 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 + findings = tr_anc.check(tampered, receipt=receipt, entry=_entry(receipt)) + assert _codes(findings)["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 + findings = tr_anc.check(claims[1], receipt=receipt, entry=_entry(receipt)) + assert _codes(findings)["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"]) +# 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) @@ -134,7 +151,8 @@ 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 + findings = tr_anc.check(claims[1], receipt=receipt, entry=_entry(receipt)) + assert _codes(findings)["TR-ANC-002"] is Status.FAIL def test_a_boolean_is_not_an_integer_leaf_index(): diff --git a/conformance/tests/test_tr_anc_subject.py b/conformance/tests/test_tr_anc_subject.py new file mode 100644 index 00000000..9de4978b --- /dev/null +++ b/conformance/tests/test_tr_anc_subject.py @@ -0,0 +1,130 @@ +"""TR-ANC-002 proves inclusion of the complete signed claim (registry-anchor-v1 section 1). + +For ``cmcp-runtime`` the runner used to hand TR-ANC the nested ``trace`` only, +while TR-SIG judged the whole envelope and the reference registry anchors the +whole envelope. A registry-valid anchor then failed the suite, and a re-signed +envelope with different gateway facts still passed against the old proof +(reported on trace-spec#533). The anchor subject is now the complete claim. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import time +from typing import Any + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from trace_tests.inclusion import InclusionError, canonical_claim_bytes, parse_entry +from trace_tests.result import Status +from trace_tests.runner import run + +LEAF = b"\x00" +URI = "https://log.example.com/entries/subject-1" + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def _sign(record: dict[str, Any], priv: Ed25519PrivateKey) -> dict[str, Any]: + body = json.dumps( + {k: v for k, v in record.items() if k != "signature"}, + sort_keys=True, separators=(",", ":"), ensure_ascii=True, + ).encode() + record["signature"] = _b64url(priv.sign(body)) + return record + + +def _envelope() -> tuple[dict[str, Any], Ed25519PrivateKey]: + priv = Ed25519PrivateKey.generate() + x = _b64url(priv.public_key().public_bytes_raw()) + record = { + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": int(time.time()) - 30, + "subject": "spiffe://cmcp.gateway/session/anchor-subject", + "runtime": {"platform": "tpm2", "measurement": "sha256:" + "a" * 64}, + "policy": {"bundle_hash": "sha256:" + "b" * 64, "enforcement_mode": "enforce"}, + "data_class": "internal", + "cnf": {"jwk": {"kty": "OKP", "crv": "Ed25519", "x": x, "kid": "subject-test"}}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example.org"}, + "transparency": URI, + }, + "gateway": {"session_id": "anchor-subject", "catalog": {"hash": "sha256:" + "c" * 64}}, + "signature": "", + } + return _sign(record, priv), priv + + +def _anchor(obj: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """A single-leaf receipt and its registry entry for *obj*.""" + root = hashlib.sha256(LEAF + canonical_claim_bytes(obj)).digest() + receipt = {"leaf_index": 0, "leaf_count": 1, "audit_path": [], + "merkle_root": "sha256:" + root.hex()} + entry = {"ts": "2026-10-16T00:00:00Z", "merkle_root": receipt["merkle_root"], + "leaf_count": 1, "producer": "subject-test/1.0.0", "batch_id": "subject-1"} + return receipt, entry + + +def _anc002(record, receipt, entry): + findings = run(record, "cmcp-runtime", 2, receipt=receipt, entry=entry)["TR-ANC"] + return next(f for f in findings if f.code == "TR-ANC-002") + + +def test_complete_envelope_anchor_passes(): + record, _ = _envelope() + receipt, entry = _anchor(record) + finding = _anc002(record, receipt, entry) + assert finding.status is Status.PASS, finding.message + assert "complete signed claim" in finding.message + + +def test_nested_trace_only_anchor_fails(): + record, _ = _envelope() + receipt, entry = _anchor(record["trace"]) + assert _anc002(record, receipt, entry).status is Status.FAIL + + +def test_resigned_envelope_with_changed_gateway_fails(): + record, priv = _envelope() + receipt, entry = _anchor(record) + changed = copy.deepcopy(record) + changed["gateway"]["session_id"] = "someone-else" + changed["gateway"]["catalog"]["hash"] = "sha256:" + "d" * 64 + _sign(changed, priv) + results = run(changed, "cmcp-runtime", 2, receipt=receipt, entry=entry) + signature_failures = [f for f in results["TR-SIG"] if f.failed()] + assert not signature_failures, "the re-signed envelope is validly signed" + assert _anc002(changed, receipt, entry).status is Status.FAIL + + +def test_changed_signature_fails(): + record, _ = _envelope() + receipt, entry = _anchor(record) + changed = copy.deepcopy(record) + changed["signature"] = _b64url(b"\x00" * 64) + assert _anc002(changed, receipt, entry).status is Status.FAIL + + +@pytest.mark.parametrize("ts", [ + "not-a-timestamp", "2026-10-16", "2026-10-16T00:00:00", "2026-10-16T00:00:00+00:00", + "2026-13-01T00:00:00Z", "2026-02-30T00:00:00Z", "2026-10-16 00:00:00Z", +]) +def test_entry_ts_must_be_utc_iso8601(ts): + _, entry = _anchor({"transparency": URI}) + entry["ts"] = ts + with pytest.raises(InclusionError, match="ts must be an ISO-8601 UTC timestamp"): + parse_entry(entry) + + +@pytest.mark.parametrize("ts", ["2026-10-16T00:00:00Z", "2026-10-16T23:59:59.123Z"]) +def test_entry_ts_accepts_utc_iso8601(ts): + _, entry = _anchor({"transparency": URI}) + entry["ts"] = ts + parse_entry(entry) diff --git a/conformance/tests/unit/test_tr_anc.py b/conformance/tests/unit/test_tr_anc.py index e1d5dd84..ab2c1e0a 100644 --- a/conformance/tests/unit/test_tr_anc.py +++ b/conformance/tests/unit/test_tr_anc.py @@ -14,6 +14,13 @@ "leaf_count": 1, "merkle_root": "sha256:" + "0" * 64, } +_ENTRY: dict[str, Any] = { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": "sha256:" + "0" * 64, + "leaf_count": 1, + "producer": "unit-test/1.0.0", + "batch_id": "unit-1", +} def _nested(depth: int = 100_000) -> list[Any]: @@ -90,7 +97,7 @@ def test_deeply_nested_claim_raises_inclusion_error_and_fails_tr_anc_002(): with pytest.raises(InclusionError, match="nests too deeply"): canonical_claim_bytes(claim) - findings = check(claim, receipt=dict(_RECEIPT)) + findings = check(claim, receipt=dict(_RECEIPT), entry=dict(_ENTRY)) by_code = {f.code: f for f in findings} assert by_code["TR-ANC-002"].status is Status.FAIL assert "nests too deeply" in by_code["TR-ANC-002"].message @@ -104,7 +111,7 @@ def test_non_json_serializable_claim_raises_inclusion_error_and_fails_tr_anc_002 with pytest.raises(InclusionError, match="not JSON serializable"): canonical_claim_bytes(claim) - findings = check(claim, receipt=dict(_RECEIPT)) + findings = check(claim, receipt=dict(_RECEIPT), entry=dict(_ENTRY)) by_code = {f.code: f for f in findings} assert by_code["TR-ANC-002"].status is Status.FAIL assert "not JSON serializable" in by_code["TR-ANC-002"].message @@ -125,7 +132,7 @@ def test_tuple_outside_anchor_profile_raises_inclusion_error_and_fails_tr_anc_00 canonical_claim_bytes(claim) trace = {"transparency": "https://registry.example.org/claim/1", **claim} - findings = check(trace, receipt=dict(_RECEIPT)) + findings = check(trace, receipt=dict(_RECEIPT), entry=dict(_ENTRY)) by_code = {f.code: f for f in findings} assert by_code["TR-ANC-002"].status is Status.FAIL assert expected in by_code["TR-ANC-002"].message diff --git a/conformance/tests/unit/test_untrusted_input_hardening.py b/conformance/tests/unit/test_untrusted_input_hardening.py index dc089c28..2e0bd462 100644 --- a/conformance/tests/unit/test_untrusted_input_hardening.py +++ b/conformance/tests/unit/test_untrusted_input_hardening.py @@ -212,7 +212,7 @@ def test_duplicate_member_cannot_ride_a_valid_signature(tmp_path: pathlib.Path) assert "duplicate" in result.output -@pytest.mark.parametrize("option", ["--receipt", "--policy-dir"]) +@pytest.mark.parametrize("option", ["--receipt", "--entry", "--policy-dir"]) @pytest.mark.parametrize( "content", [b"\xff\xfe{\x00}\x00", b"[" * 100_000, b'{"a": "x.json", "a": "y.json"}'], @@ -222,8 +222,8 @@ def test_cli_side_inputs_exit_2_not_traceback( tmp_path: pathlib.Path, option: str, content: bytes ) -> None: record = VECTORS / "valid_level0.json" - if option == "--receipt": - side = tmp_path / "receipt.json" + if option in ("--receipt", "--entry"): + side = tmp_path / "side.json" arg = str(side) else: side = tmp_path / "resolutions.json" @@ -244,6 +244,17 @@ def _single_leaf_receipt(claim: dict[str, Any]) -> dict[str, Any]: return {"leaf_index": 0, "leaf_count": 1, "audit_path": [], "merkle_root": "sha256:" + root} +def _entry_for(receipt: dict[str, Any]) -> dict[str, Any]: + """The registry entry the receipt commits to (registry-anchor-v1 section 4).""" + return { + "ts": "2026-10-15T00:00:00Z", + "merkle_root": receipt["merkle_root"], + "leaf_count": receipt["leaf_count"], + "producer": "hardening/1.0.0", + "batch_id": "hardening-1", + } + + @pytest.mark.parametrize( "value", [1.5, 2**53, -(2**53), float("nan"), float("inf"), [{"x": 0.1}]], @@ -256,7 +267,8 @@ def test_claim_outside_the_anchor_profile_is_not_proven(value: Any) -> None: proof over them proves inclusion of bytes only Python's json.dumps writes. """ claim = {"transparency": "https://log.example.com/e/1", "extra": value} - findings = tr_anc.check(claim, receipt=_single_leaf_receipt(claim)) + receipt = _single_leaf_receipt(claim) + findings = tr_anc.check(claim, receipt=receipt, entry=_entry_for(receipt)) assert _status(findings, "TR-ANC-002") is Status.FAIL @@ -268,7 +280,8 @@ def test_claim_inside_the_anchor_profile_is_proven() -> None: "ok": True, "none": None, } - findings = tr_anc.check(claim, receipt=_single_leaf_receipt(claim)) + receipt = _single_leaf_receipt(claim) + findings = tr_anc.check(claim, receipt=receipt, entry=_entry_for(receipt)) assert _status(findings, "TR-ANC-002") is Status.PASS diff --git a/conformance/tests/vectors/anchor-inclusion/README.md b/conformance/tests/vectors/anchor-inclusion/README.md index 8256718e..315cca6b 100644 --- a/conformance/tests/vectors/anchor-inclusion/README.md +++ b/conformance/tests/vectors/anchor-inclusion/README.md @@ -6,7 +6,10 @@ ASCII receipts. The normative source is Each numbered JSON file supplies `claim`, `receipt`, and `expected.tr_anc_002`. The receipt combines the proof's index/path with the entry's count/root, as -accepted by `trace-tests --receipt`. Expected results concern **only inclusion**, +accepted by `trace-tests --receipt`; `tests/test_anchor_vectors.py` supplies a +matching registry entry, so the receipt-to-entry binding (trace-spec#464, tested in +`tests/test_tr_anc_entry_binding.py`) holds and only the replay is under test. +Expected results concern **only inclusion**, not the whole record's conformance level. Claims are deliberately small objects; `signature` is an opaque fixture string, not a cryptographically valid signature. Inclusion does not authenticate signatures or assert anything about their truth. diff --git a/spec/registry-anchor-v1.md b/spec/registry-anchor-v1.md index d030ef89..0c52978a 100644 --- a/spec/registry-anchor-v1.md +++ b/spec/registry-anchor-v1.md @@ -147,6 +147,10 @@ TRACE v0.2 makes `transparency` optional below Level 2, because an unanchored Le At **Level 2 and above the anchor is what makes the record independently checkable**, so a verifier appraising a Level 2 record MUST be able to retrieve the entry and check inclusion without contacting the record's issuer. A `transparency` value that resolves only through the issuer's own infrastructure does not satisfy this. +A Level 2 verdict MUST rest on the registry entry itself, held by the verifier as the third input of §5.1, and not on a `merkle_root` and `leaf_count` carried only beside the proof. Where a receipt carries its own `merkle_root` or `leaf_count`, the verifier MUST check that each equals the entry's and MUST refuse the anchor if either differs. A verifier that does not hold the entry MUST NOT report Level 2. + +This document does not yet define how an entry is identified from the `transparency` value, so a verifier cannot check that the entry it holds is the one the record names. Until that is defined, a Level 2 verdict establishes inclusion in the entry the verifier was given, and a verifier MUST state that the link from `transparency` to that entry was not checked. Defining the link is tracked as finding 21 of [trace-spec#448](https://github.com/agentrust-io/trace-spec/issues/448). + ## 7. Reference implementations Named so that "here is the algorithm, and here is one implementation that is not the definition" is available as a pairing. Neither is normative.