Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ All TRACE test failures emit a structured error code of the form `TR-<MODULE>-<N

| 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 |
| 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-SCA — Provenance

Expand Down
1 change: 1 addition & 0 deletions docs/levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ Level 2 adds tool transcript and transparency anchor requirements. The `transpar
- `tool_transcript.hash` missing or not a valid `sha256:` 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`

---

Expand Down
2 changes: 1 addition & 1 deletion docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ The TRACE conformance suite is divided into seven modules. Each module maps to a
| [Runtime](modules/tr-rte.md) | TR-RTE | §3.1 | TEE platform enum, measurement format, RIM URI scheme |
| [Policy](modules/tr-pol.md) | TR-POL | §3.1 | Policy bundle hash format, enforcement mode values |
| [Transcript](modules/tr-txn.md) | TR-TXN | §3.1 | Tool-call transcript hash binding |
| [Transparency](modules/tr-anc.md) | TR-ANC | §3.2 | SCITT receipt URI form. The URI is not resolved and no inclusion proof is checked |
| [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 |
| [Provenance](modules/tr-sca.md) | TR-SCA | §3.1 | SLSA provenance level and digest format |
1 change: 1 addition & 0 deletions docs/modules/tr-anc.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ Tests transparency anchoring via SCITT.
| 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 |
48 changes: 47 additions & 1 deletion src/trace_tests/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import datetime as _dt
import json
import importlib.metadata
import pathlib
import sys
Expand Down Expand Up @@ -81,6 +82,28 @@ def _print_report(path: str, fmt: str, level: int, results: dict[str, list[Any]]
return 1



def _load_receipt(path: str | None) -> dict | None:
"""Load and shape-check an anchor receipt, or return None when not supplied."""
if path is None:
return None
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except OSError as exc:
click.echo(f"Error: cannot read receipt {path}: {exc}", err=True)
sys.exit(2)
except json.JSONDecodeError as exc:
click.echo(f"Error: receipt {path} is not valid JSON: {exc}", err=True)
sys.exit(2)
if not isinstance(data, dict):
click.echo(
f"Error: receipt {path} must be a JSON object, got {type(data).__name__}",
err=True,
)
sys.exit(2)
return data

@click.group()
@click.version_option(__version__)
def main() -> None:
Expand Down Expand Up @@ -109,20 +132,33 @@ def main() -> None:
default=None,
help="Verifier-issued challenge nonce; required for Level 1 and Level 2.",
)
def verify(record: str, level: int, max_age: int, expected_nonce: str | None) -> None:
@click.option(
"--receipt",
default=None,
type=click.Path(),
help=(
"Path to the anchor receipt (JSON) proving the record is included in the "
"transparency log. Required for TR-ANC-002 at Level 2: the transparency URI "
"says where the anchor lives, the receipt is what proves the record is in it."
),
)
def verify(record: str, level: int, max_age: int, expected_nonce: str | None, receipt: str | None) -> None:
"""Verify a TRACE trust record against the conformance suite."""
try:
data, fmt = load_record(record)
except LoadError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(2)

receipt_data = _load_receipt(receipt)

results = run(
data,
fmt,
level,
max_age_seconds=max_age,
expected_nonce=expected_nonce,
receipt=receipt_data,
)
exit_code = _print_report(record, fmt, level, results)
sys.exit(exit_code)
Expand Down Expand Up @@ -162,6 +198,12 @@ def verify(record: str, level: int, max_age: int, expected_nonce: str | None) ->
default=None,
help="Verifier-issued challenge nonce; required for Level 1 and Level 2.",
)
@click.option(
"--receipt",
default=None,
type=click.Path(),
help="Path to the anchor receipt (JSON). Required for TR-ANC-002 at Level 2.",
)
def report(
record: str,
max_level: int,
Expand All @@ -171,6 +213,7 @@ def report(
badge_out: str | None,
fail_under: int | None,
expected_nonce: str | None,
receipt: str | None,
) -> None:
"""Produce a conformance report you can hand to someone else.

Expand All @@ -184,13 +227,16 @@ def report(
click.echo(f"Error: {exc}", err=True)
sys.exit(2)

receipt_data = _load_receipt(receipt)

results_by_level = {
level: run(
data,
fmt,
level,
max_age_seconds=max_age,
expected_nonce=expected_nonce,
receipt=receipt_data,
)
for level in range(max_level + 1)
}
Expand Down
110 changes: 110 additions & 0 deletions src/trace_tests/inclusion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""RFC 9162 inclusion-proof verification for TRACE anchor receipts.

Standard library only, and deliberately self-contained so it can be audited or
reimplemented in isolation. This is the same algorithm as
``tools/verify_inclusion.py`` in ``agentrust-io/trace-registry``, ported here so
the conformance suite can check an anchor offline rather than trusting a URI.

**On canonicalisation.** The leaf pre-image is sorted-key ASCII JSON, not
RFC 8785 JCS. That is not an oversight and must not be "fixed": TRACE uses two
canonicalisations by design, JCS for the signature pre-image and sorted-key
ASCII for the anchor leaf, specified in ``registry-anchor-v1.md`` section 0. A
verifier that used JCS here would recompute a different leaf and reject every
genuine proof.
"""

from __future__ import annotations

import hashlib
import json
import re
from typing import Any

LEAF_PREFIX = b"\x00"
NODE_PREFIX = b"\x01"
_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")

__all__ = ["InclusionError", "canonical_claim_bytes", "decode_hash", "verify_inclusion"]


class InclusionError(ValueError):
"""The receipt is malformed, as opposed to proving nothing."""


def canonical_claim_bytes(claim: dict[str, Any]) -> bytes:
"""Canonical anchor-leaf JSON bytes of the complete signed claim."""
if not isinstance(claim, dict):
raise InclusionError("claim must be a JSON object")
return json.dumps(claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")


def decode_hash(value: object) -> bytes:
"""Decode ``sha256:<64 lowercase hex>`` to 32 raw bytes."""
if not isinstance(value, str) or not _HASH_RE.match(value):
raise InclusionError(f"malformed hash value: {value!r}")
return bytes.fromhex(value.split(":", 1)[1])


def verify_inclusion(
claim: dict[str, Any],
leaf_index: int,
audit_path: list[bytes],
leaf_count: int,
merkle_root: bytes,
) -> bool:
"""Return True iff *claim*'s leaf is proven included under *merkle_root*.

RFC 9162 section 2.1.3.2 inclusion-proof verification over an RFC 6962 tree.
"""
if not isinstance(leaf_index, int) or isinstance(leaf_index, bool):
return False
if not isinstance(leaf_count, int) or isinstance(leaf_count, bool):
return False
if leaf_index < 0 or leaf_count < 1 or leaf_index >= leaf_count:
return False

r = hashlib.sha256(LEAF_PREFIX + canonical_claim_bytes(claim)).digest()
fn, sn = leaf_index, leaf_count - 1

for p in audit_path:
if sn == 0:
return False # path longer than the tree height
if fn & 1 or fn == sn:
r = hashlib.sha256(NODE_PREFIX + p + r).digest()
if not fn & 1:
# Right edge: skip levels whose ancestor was promoted unpaired.
while fn and not fn & 1:
fn >>= 1
sn >>= 1
else:
r = hashlib.sha256(NODE_PREFIX + r + p).digest()
fn >>= 1
sn >>= 1

return sn == 0 and r == merkle_root


def parse_receipt(receipt: object) -> tuple[int, list[bytes], int, bytes]:
"""Validate a receipt object and return (leaf_index, audit_path, leaf_count, merkle_root).

Raises InclusionError with a specific reason rather than returning a bare
False, so a malformed receipt and a receipt that proves nothing are
reported differently.
"""
if not isinstance(receipt, dict):
raise InclusionError(f"receipt must be a JSON object, got {type(receipt).__name__}")

missing = [k for k in ("leaf_index", "audit_path", "leaf_count", "merkle_root") if k not in receipt]
if missing:
raise InclusionError(f"receipt is missing required field(s): {', '.join(missing)}")

raw_path = receipt["audit_path"]
if not isinstance(raw_path, list):
raise InclusionError(f"audit_path must be an array, got {type(raw_path).__name__}")

return (
receipt["leaf_index"],
[decode_hash(node) for node in raw_path],
receipt["leaf_count"],
decode_hash(receipt["merkle_root"]),
)
69 changes: 58 additions & 11 deletions src/trace_tests/modules/tr_anc.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
"""TR-ANC: Transparency anchoring checks (spec §3.2)."""
"""TR-ANC: Transparency anchoring checks (spec section 3.2)."""

from __future__ import annotations

from typing import Any
from urllib.parse import urlparse

from trace_tests.inclusion import InclusionError, parse_receipt, verify_inclusion
from trace_tests.result import Finding, Status


def check(trace: dict[str, Any]) -> list[Finding]:
"""Return TR-ANC findings for the transparency claim."""
def check(trace: dict[str, Any], receipt: dict[str, Any] | None = None) -> list[Finding]:
"""Return TR-ANC findings for the transparency claim.

TR-ANC-001 checks the shape of the ``transparency`` URI. TR-ANC-002 checks
that the record is actually anchored, by replaying the inclusion proof in
*receipt* against the committed Merkle root. Without a receipt there is
nothing to replay, and TR-ANC-002 fails: Level 2 means anchored, and a URI
is a pointer at an anchor rather than evidence of one.
"""
findings: list[Finding] = []
transparency = trace.get("transparency")

Expand All @@ -21,14 +29,53 @@ def check(trace: dict[str, Any]) -> list[Finding]:

try:
parsed = urlparse(transparency)
if parsed.scheme == "https" and parsed.netloc:
findings.append(Finding("TR-ANC-001", Status.PASS, f"transparency is a valid URI ({transparency[:80]})"))
else:
findings.append(Finding(
"TR-ANC-001", Status.FAIL,
f"TR-ANC-001: transparency must be an https URI, got scheme={parsed.scheme!r}",
))
except Exception as exc:
findings.append(Finding("TR-ANC-001", Status.FAIL, f"TR-ANC-001: could not parse transparency URI: {exc}"))
return [Finding("TR-ANC-001", Status.FAIL, f"TR-ANC-001: could not parse transparency URI: {exc}")]

if parsed.scheme != "https" or not parsed.netloc:
return [Finding(
"TR-ANC-001", Status.FAIL,
f"TR-ANC-001: transparency must be an https URI, got scheme={parsed.scheme!r}",
)]

findings.append(Finding(
"TR-ANC-001", Status.PASS,
f"transparency is a well-formed https URI ({transparency[:80]}); "
"this checks the pointer, not the anchor (see TR-ANC-002)",
))
findings.append(_check_inclusion(trace, receipt))
return findings


def _check_inclusion(trace: dict[str, Any], receipt: dict[str, Any] | None) -> Finding:
"""Replay the inclusion proof, or say why it could not be replayed."""
if receipt is None:
return Finding(
"TR-ANC-002", Status.FAIL,
"TR-ANC-002: no anchor receipt supplied, so inclusion was not proven. "
"The transparency URI names where the anchor lives; it is not evidence "
"the record is in it. Pass the receipt with --receipt.",
)

try:
leaf_index, audit_path, leaf_count, merkle_root = parse_receipt(receipt)
except InclusionError as exc:
return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: malformed anchor receipt: {exc}")

try:
proven = verify_inclusion(trace, leaf_index, audit_path, leaf_count, merkle_root)
except InclusionError as exc:
return Finding("TR-ANC-002", Status.FAIL, f"TR-ANC-002: could not verify inclusion: {exc}")

if proven:
return Finding(
"TR-ANC-002", Status.PASS,
f"inclusion proven against merkle_root {merkle_root.hex()[:16]}... "
f"(leaf {leaf_index} of {leaf_count})",
)
return Finding(
"TR-ANC-002", Status.FAIL,
f"TR-ANC-002: inclusion proof does not reproduce the committed merkle_root "
f"(leaf {leaf_index} of {leaf_count}). The record is not in the tree this "
"receipt commits to, or it has been modified since it was anchored.",
)
3 changes: 2 additions & 1 deletion src/trace_tests/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def run(
level: int,
max_age_seconds: int = tr_env.DEFAULT_MAX_AGE_SECONDS,
expected_nonce: str | None = None,
receipt: dict[str, Any] | None = None,
) -> dict[str, list[Finding]]:
"""Run all modules required for *level* and return findings keyed by module ID."""
if level not in _LEVEL_MODULES:
Expand Down Expand Up @@ -51,6 +52,6 @@ def run(
results["TR-TXN"] = tr_txn.check(trace)

if "TR-ANC" in active:
results["TR-ANC"] = tr_anc.check(trace)
results["TR-ANC"] = tr_anc.check(trace, receipt=receipt)

return results
Loading