diff --git a/provider/src/pacta_provider/cli.py b/provider/src/pacta_provider/cli.py index 2a392e1..0853c24 100644 --- a/provider/src/pacta_provider/cli.py +++ b/provider/src/pacta_provider/cli.py @@ -64,6 +64,16 @@ def build_parser() -> argparse.ArgumentParser: log_append.add_argument("--out", required=True) log_append.set_defaults(func=cmd_log_append) + log_consistency = sub.add_parser("log-consistency", help="Emit a consistency proof from an earlier tree size (for pinning agents).") + log_consistency.add_argument("--log-dir", required=True) + log_consistency.add_argument("--from-size", type=int, required=True) + log_consistency.add_argument("--out", help="Write the proof document here (default: stdout).") + log_consistency.set_defaults(func=cmd_log_consistency) + + log_audit = sub.add_parser("log-audit", help="Monitor check: recompute the tree, verify the stored STH and append-only structure.") + log_audit.add_argument("--log-dir", required=True) + log_audit.set_defaults(func=cmd_log_audit) + log_sth = sub.add_parser("log-sth", help="Sign and print the latest transparency-log tree head.") log_sth.add_argument("--log-dir", default="provider/state/transparency-log") log_sth.add_argument("--private-key", required=True) @@ -131,6 +141,36 @@ def cmd_log_append(args: argparse.Namespace) -> int: return 0 +def cmd_log_consistency(args) -> int: + from pacta.yamlio import dump_data + + log = TransparencyLog(args.log_dir) + document = log.consistency_from(args.from_size) + if args.out: + dump_data(document, args.out) + print(f"consistency proof: {args.out}") + else: + for key in ("log_id", "from_tree_size", "from_root_hash", "to_tree_size", "to_root_hash"): + print(f"{key}: {document[key]}") + for item in document["proof"]: + print(f" {item}") + return 0 + + +def cmd_log_audit(args) -> int: + log = TransparencyLog(args.log_dir) + report = log.audit() + print(f"tree_size: {report['tree_size']}") + print(f"computed_root: {report['computed_root']}") + print(f"stored_sth_root: {report['stored_sth_root']}") + if report["problems"]: + print("problems:") + for problem in report["problems"]: + print(f" - {problem}") + print(f"ok: {str(report['ok']).lower()}") + return 0 if report["ok"] else 1 + + def cmd_log_sth(args: argparse.Namespace) -> int: sth = TransparencyLog(args.log_dir).latest_sth(args.private_key, args.public_key) print(json.dumps(sth, indent=2, sort_keys=True)) diff --git a/provider/src/pacta_provider/transparency_log.py b/provider/src/pacta_provider/transparency_log.py index da7d66e..a6c50c2 100644 --- a/provider/src/pacta_provider/transparency_log.py +++ b/provider/src/pacta_provider/transparency_log.py @@ -174,5 +174,52 @@ class TransparencyLog: return receipt + def consistency_from(self, old_tree_size: int) -> dict[str, Any]: + """Consistency proof from an arbitrary earlier tree size - what a + pinning agent requests when its pin is older than the receipt's + embedded from_tree_size.""" + entries = self.entries() + leaves = [entry.leaf_bytes() for entry in entries] + if old_tree_size < 0 or old_tree_size > len(leaves): + raise ValueError(f"old_tree_size {old_tree_size} outside tree size {len(leaves)}") + return { + "schema_version": 1, + "type": "pacta.transparency.consistency_proof.v1", + "log_id": self.metadata()["log_id"], + "from_tree_size": old_tree_size, + "from_root_hash": merkle_root(leaves[:old_tree_size]).hex(), + "to_tree_size": len(leaves), + "to_root_hash": merkle_root(leaves).hex(), + "proof": proof_to_hex(consistency_proof(leaves, old_tree_size)), + } + + def audit(self) -> dict[str, Any]: + """Monitor-side self-check: recompute every prefix root, confirm the + stored STH matches the full tree, and confirm every prefix is + consistent with the final tree (append-only structure).""" + entries = self.entries() + leaves = [entry.leaf_bytes() for entry in entries] + problems: list[str] = [] + for position, entry in enumerate(entries): + if entry.index != position: + problems.append(f"Entry at position {position} carries index {entry.index}.") + if leaf_hash(entry.leaf_bytes()).hex() != entry.leaf_hash: + problems.append(f"Entry {position} leaf_hash does not match its leaf bytes.") + computed_root = merkle_root(leaves).hex() + stored_sth = load_data(self.sth_path) if self.sth_path.exists() else None + if stored_sth: + if stored_sth.get("tree_size") != len(leaves): + problems.append("Stored STH tree_size does not match the entry count.") + if stored_sth.get("root_hash") != computed_root: + problems.append("Stored STH root hash does not match the recomputed tree root.") + return { + "tree_size": len(leaves), + "computed_root": computed_root, + "stored_sth_root": (stored_sth or {}).get("root_hash"), + "problems": problems, + "ok": not problems, + } + + def _now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/pacta/attestation.py b/src/pacta/attestation.py index e7e3bc1..e8b8a0b 100644 --- a/src/pacta/attestation.py +++ b/src/pacta/attestation.py @@ -7,6 +7,7 @@ from typing import Any from .config import RepoConfig from .profiles import get_profile from .signing import verify_attestation_signature +from .sthstore import check_sth_against_store, check_sth_freshness from .transparency import load_receipt, verify_receipt from .yamlio import load_data @@ -41,6 +42,9 @@ def validate_attestation( transparency_log_public_key_path: str | Path | None = None, require_transparency_signatures: str = "ed25519", require_transparency_receipt: bool = False, + sth_store_path: str | Path | None = None, + consistency_proof_path: str | Path | None = None, + max_sth_age_seconds: int | None = None, ) -> AttestationResult: provider = raw.get("provider") subject = raw.get("subject") or {} @@ -112,6 +116,25 @@ def validate_attestation( transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path) if not receipt_result.accepted: diagnostics.extend(receipt_result.diagnostics) + sth = receipt.get("sth") or {} + if max_sth_age_seconds is not None: + fresh, error = check_sth_freshness(sth, int(max_sth_age_seconds)) + if not fresh: + diagnostics.append(error or "Signed tree head fails the freshness policy.") + if sth_store_path: + proof_hex = None + if consistency_proof_path: + raw_proof = load_data(consistency_proof_path) + proof_hex = [str(item) for item in (raw_proof.get("proof") if isinstance(raw_proof, dict) else raw_proof) or []] + sth_check = check_sth_against_store( + sth, + sth_store_path, + consistency_proof_hex=proof_hex, + consistency_from=receipt.get("consistency"), + ) + transparency_evidence.update(sth_check.evidence()) + if not sth_check.ok: + diagnostics.extend("STH store: " + note for note in sth_check.diagnostics) accepted = not diagnostics evidence = { diff --git a/src/pacta/cli.py b/src/pacta/cli.py index 9a86911..00626a6 100644 --- a/src/pacta/cli.py +++ b/src/pacta/cli.py @@ -24,6 +24,7 @@ from .profiles import get_profile from .repo import clone_or_fetch, status_for from .report import render_markdown from .risk import score_claim_card +from .sthstore import check_sth_against_store, check_sth_freshness from .transparency import load_receipt, verify_receipt from .yamlio import dump_data, load_data @@ -111,6 +112,9 @@ def build_parser() -> argparse.ArgumentParser: claims.add_argument("--transparency-log-public-key") claims.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519") claims.add_argument("--require-transparency-receipt", action="store_true") + claims.add_argument("--sth-store") + claims.add_argument("--consistency-proof") + claims.add_argument("--max-sth-age-seconds", type=int) claims.set_defaults(func=cmd_claims) report = sub.add_parser("report", help="Generate a human-readable Markdown risk report.") @@ -132,6 +136,9 @@ def build_parser() -> argparse.ArgumentParser: receipt_verify.add_argument("--receipt", required=True) receipt_verify.add_argument("--log-public-key", required=True) receipt_verify.add_argument("--require-signatures", choices=["ed25519", "both"], default="ed25519") + receipt_verify.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).") + receipt_verify.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size (provider: log-consistency).") + receipt_verify.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this (freshness policy).") receipt_verify.set_defaults(func=cmd_receipt_verify) agent = sub.add_parser("agent", help="Apply a policy-gated consequence to verification evidence.") @@ -159,6 +166,9 @@ def build_parser() -> argparse.ArgumentParser: agent.add_argument("--transparency-log-public-key") agent.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519") agent.add_argument("--require-transparency-receipt", action="store_true") + agent.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).") + agent.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size.") + agent.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this.") agent.set_defaults(func=cmd_agent) return parser @@ -369,6 +379,15 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int: attestation = load_attestation(args.attestation) receipt = load_receipt(args.receipt) result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures) + accountability_diagnostics = _log_accountability_checks( + receipt, + sth_store=args.sth_store, + consistency_proof_path=args.consistency_proof, + max_sth_age_seconds=args.max_sth_age_seconds, + ) + if accountability_diagnostics: + result.accepted = False + result.diagnostics.extend(accountability_diagnostics) print(f"accepted: {str(result.accepted).lower()}") print(f"log_id: {result.log_id or 'unknown'}") print(f"tree_size: {result.tree_size if result.tree_size is not None else 'unknown'}") @@ -383,6 +402,40 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int: return 0 if result.accepted else 1 +def _log_accountability_checks( + receipt: dict, + sth_store: str | None, + consistency_proof_path: str | None, + max_sth_age_seconds: int | None, +) -> list[str]: + diagnostics: list[str] = [] + sth = receipt.get("sth") or {} + if max_sth_age_seconds is not None: + fresh, error = check_sth_freshness(sth, max_sth_age_seconds) + if not fresh: + diagnostics.append(error or "Signed tree head fails the freshness policy.") + if sth_store: + proof_hex = None + if consistency_proof_path: + from .yamlio import load_data + + raw = load_data(consistency_proof_path) + proof_hex = [str(item) for item in (raw.get("proof") if isinstance(raw, dict) else raw) or []] + check = check_sth_against_store( + sth, + sth_store, + consistency_proof_hex=proof_hex, + consistency_from=receipt.get("consistency"), + ) + for note in check.diagnostics: + prefix = "" if check.ok else "STH store: " + if check.ok: + print(f"sth-store: {note}") + else: + diagnostics.append(prefix + note) + return diagnostics + + def cmd_agent(args: argparse.Namespace) -> int: card = _card_for_agent(args) decision = run_agent_action( @@ -497,4 +550,7 @@ def _attestation_for_args(args: argparse.Namespace, repo: RepoConfig): transparency_log_public_key_path=getattr(args, "transparency_log_public_key", None), require_transparency_signatures=str(getattr(args, "require_transparency_signatures", "ed25519")), require_transparency_receipt=bool(getattr(args, "require_transparency_receipt", False)), + sth_store_path=getattr(args, "sth_store", None), + consistency_proof_path=getattr(args, "consistency_proof", None), + max_sth_age_seconds=getattr(args, "max_sth_age_seconds", None), ) diff --git a/src/pacta/sthstore.py b/src/pacta/sthstore.py new file mode 100644 index 0000000..501ed22 --- /dev/null +++ b/src/pacta/sthstore.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .transparency import verify_consistency + +STORE_TYPE = "pacta.transparency.sth_store.v1" + + +@dataclass(slots=True) +class SthCheckResult: + ok: bool + diagnostics: list[str] = field(default_factory=list) + action: str = "none" # pinned_first_use | matched | advanced | rejected + + def evidence(self) -> dict[str, Any]: + return { + "transparency_sth_store_status": self.action if self.ok else "rejected", + "transparency_sth_store_diagnostics": self.diagnostics, + } + + +def load_store(path: str | Path) -> dict[str, Any]: + store_path = Path(path) + if not store_path.exists(): + return {"schema_version": 1, "type": STORE_TYPE, "logs": {}} + raw = json.loads(store_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or raw.get("type") != STORE_TYPE: + raise ValueError(f"Not an STH store: {store_path}") + raw.setdefault("logs", {}) + return raw + + +def save_store(store: dict[str, Any], path: str | Path) -> None: + store_path = Path(path) + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def check_sth_against_store( + sth: dict[str, Any], + store_path: str | Path, + consistency_proof_hex: list[str] | None = None, + consistency_from: dict[str, Any] | None = None, + update: bool = True, + now: datetime | None = None, +) -> SthCheckResult: + """Split-view / rollback defense: compare a signed tree head against the + locally pinned view of the same log. + + Policy: + - unknown log_id: pin it (trust-on-first-use, recorded as such); + - same tree_size: the root hash must match the pin exactly - two + different roots at one size is EQUIVOCATION, a hard, unrecoverable + rejection; + - larger tree_size: a consistency proof from the PINNED size is + required and must verify; only then does the pin advance; + - smaller tree_size: log rollback - hard rejection. + + The consistency proof may come from the receipt itself (when the pinned + size equals the receipt's from_tree_size) or from the provider's + log-consistency command for older pins. + """ + diagnostics: list[str] = [] + log_id = str(sth.get("log_id") or "") + if not log_id: + return SthCheckResult(False, ["Signed tree head has no log_id."], "rejected") + try: + tree_size = int(sth.get("tree_size")) + root_hash = str(sth.get("root_hash") or "") + assert root_hash + except (TypeError, ValueError, AssertionError): + return SthCheckResult(False, ["Signed tree head has invalid tree_size or root_hash."], "rejected") + + store = load_store(store_path) + pinned = store["logs"].get(log_id) + stamp = (now or datetime.now(timezone.utc)).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + if pinned is None: + store["logs"][log_id] = { + "tree_size": tree_size, + "root_hash": root_hash, + "sth_timestamp": sth.get("timestamp"), + "first_seen": stamp, + "last_updated": stamp, + "trust_origin": "trust_on_first_use", + } + if update: + save_store(store, store_path) + return SthCheckResult( + True, + [f"Log {log_id[:16]}… pinned on first use at tree_size {tree_size} (trust-on-first-use)."], + "pinned_first_use", + ) + + pinned_size = int(pinned["tree_size"]) + pinned_root = str(pinned["root_hash"]) + + if tree_size == pinned_size: + if root_hash == pinned_root: + return SthCheckResult(True, [], "matched") + return SthCheckResult( + False, + [ + "EQUIVOCATION: the log presented a different root hash at the pinned tree size " + f"{pinned_size} (pinned {pinned_root[:16]}…, presented {root_hash[:16]}…). " + "This log is maintaining a split view and must not be trusted again." + ], + "rejected", + ) + + if tree_size < pinned_size: + return SthCheckResult( + False, + [ + f"LOG ROLLBACK: presented tree_size {tree_size} is smaller than the pinned size " + f"{pinned_size}. Append-only logs never shrink." + ], + "rejected", + ) + + # tree grew: demand a consistency proof from the pinned size + proof_hex = None + if consistency_from is not None: + from_size = int(consistency_from.get("from_tree_size", -1)) + from_root = str(consistency_from.get("from_root_hash") or "") + if from_size == pinned_size: + if from_root != pinned_root: + return SthCheckResult( + False, + [ + "EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at " + f"tree_size {pinned_size}." + ], + "rejected", + ) + proof_hex = list(consistency_from.get("proof") or []) + if proof_hex is None and consistency_proof_hex is not None: + proof_hex = list(consistency_proof_hex) + if proof_hex is None: + return SthCheckResult( + False, + [ + f"The log grew from pinned size {pinned_size} to {tree_size} but no consistency proof " + "from the pinned size was supplied. Obtain one (provider: log-consistency " + f"--from-size {pinned_size}) - growth without proof is indistinguishable from a split view." + ], + "rejected", + ) + try: + proof = [bytes.fromhex(item) for item in proof_hex] + old_root = bytes.fromhex(pinned_root) + new_root = bytes.fromhex(root_hash) + except ValueError as exc: + return SthCheckResult(False, [f"Consistency proof contains invalid hex: {exc}"], "rejected") + if not verify_consistency(pinned_size, tree_size, old_root, new_root, proof): + return SthCheckResult( + False, + [ + f"Consistency proof from pinned size {pinned_size} to {tree_size} does NOT verify: " + "the new tree is not an append-only extension of the pinned tree." + ], + "rejected", + ) + pinned.update( + { + "tree_size": tree_size, + "root_hash": root_hash, + "sth_timestamp": sth.get("timestamp"), + "last_updated": stamp, + } + ) + if update: + save_store(store, store_path) + return SthCheckResult( + True, + [f"Pin advanced {pinned_size} → {tree_size} with a verified consistency proof."], + "advanced", + ) + + +def check_sth_freshness( + sth: dict[str, Any], + max_age_seconds: int, + now: datetime | None = None, +) -> tuple[bool, str | None]: + """Stale-root defense: an old-but-valid STH can hide later log entries + (or later revocations). Policies that require freshness reject tree + heads older than max_age_seconds.""" + raw = sth.get("timestamp") + if not raw: + return False, "Signed tree head has no timestamp; freshness policy cannot be evaluated." + try: + stamp = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError: + return False, f"Signed tree head timestamp is not ISO 8601: {raw!r}" + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + current = now or datetime.now(timezone.utc) + age = (current - stamp).total_seconds() + if age < 0: + return False, f"Signed tree head timestamp is {int(-age)}s in the future; clock skew or forgery." + if age > max_age_seconds: + return False, ( + f"Signed tree head is {int(age)}s old, beyond the freshness policy of {max_age_seconds}s. " + "Request a fresh tree head from the provider." + ) + return True, None diff --git a/tests/test_sthstore.py b/tests/test_sthstore.py new file mode 100644 index 0000000..8a33d05 --- /dev/null +++ b/tests/test_sthstore.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone + +from pacta.sthstore import check_sth_against_store, check_sth_freshness +from pacta.transparency import consistency_proof, merkle_root, proof_to_hex + + +def _sth(size, root, log_id="log-1", timestamp="2026-07-06T00:00:00Z"): + return {"log_id": log_id, "tree_size": size, "root_hash": root, "timestamp": timestamp} + + +def _tree(n): + return [f"leaf-{i}".encode() for i in range(n)] + + +def test_first_use_pins_then_same_size_must_match(tmp_path): + store = tmp_path / "sth.json" + root = merkle_root(_tree(3)).hex() + first = check_sth_against_store(_sth(3, root), store) + assert first.ok and first.action == "pinned_first_use" + again = check_sth_against_store(_sth(3, root), store) + assert again.ok and again.action == "matched" + evil = check_sth_against_store(_sth(3, merkle_root(_tree(4)).hex()), store) + assert not evil.ok + assert any("EQUIVOCATION" in d for d in evil.diagnostics) + + +def test_growth_requires_consistency_proof_and_advances(tmp_path): + store = tmp_path / "sth.json" + leaves = _tree(5) + old_root = merkle_root(leaves[:2]).hex() + new_root = merkle_root(leaves).hex() + assert check_sth_against_store(_sth(2, old_root), store).ok + # growth without proof: rejected + bare = check_sth_against_store(_sth(5, new_root), store) + assert not bare.ok and any("consistency proof" in d for d in bare.diagnostics) + # growth with a valid proof: pin advances + proof = proof_to_hex(consistency_proof(leaves, 2)) + good = check_sth_against_store(_sth(5, new_root), store, consistency_proof_hex=proof) + assert good.ok and good.action == "advanced" + # and the pin really moved + assert check_sth_against_store(_sth(5, new_root), store).action == "matched" + + +def test_receipt_embedded_consistency_anchor_is_checked(tmp_path): + store = tmp_path / "sth.json" + leaves = _tree(4) + old_root = merkle_root(leaves[:3]).hex() + new_root = merkle_root(leaves).hex() + assert check_sth_against_store(_sth(3, old_root), store).ok + # anchor size matches the pin but anchor ROOT lies about history + lying_anchor = { + "from_tree_size": 3, + "from_root_hash": merkle_root(_tree(9)).hex(), + "proof": proof_to_hex(consistency_proof(leaves, 3)), + } + out = check_sth_against_store(_sth(4, new_root), store, consistency_from=lying_anchor) + assert not out.ok and any("EQUIVOCATION" in d for d in out.diagnostics) + honest_anchor = dict(lying_anchor, from_root_hash=old_root) + assert check_sth_against_store(_sth(4, new_root), store, consistency_from=honest_anchor).ok + + +def test_rollback_is_rejected(tmp_path): + store = tmp_path / "sth.json" + assert check_sth_against_store(_sth(7, merkle_root(_tree(7)).hex()), store).ok + rolled = check_sth_against_store(_sth(3, merkle_root(_tree(3)).hex()), store) + assert not rolled.ok and any("ROLLBACK" in d for d in rolled.diagnostics) + + +def test_freshness_policy(): + now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc) + fresh, _ = check_sth_freshness(_sth(1, "aa", timestamp="2026-07-06T11:59:30Z"), 60, now=now) + assert fresh + stale, error = check_sth_freshness(_sth(1, "aa", timestamp="2026-07-06T10:00:00Z"), 60, now=now) + assert not stale and "freshness policy" in error + future, error = check_sth_freshness(_sth(1, "aa", timestamp="2026-07-06T13:00:00Z"), 60, now=now) + assert not future and "future" in error + missing, error = check_sth_freshness({"log_id": "x"}, 60, now=now) + assert not missing