proof-aware-crypto-tooling-.../src/pacta/sthstore.py

213 lines
7.9 KiB
Python
Raw Normal View History

Log accountability: STH pinning, consistency enforcement, freshness, monitor A transparency log without split-view defense is just a signature with extra steps: the provider could serve one tree to the agent and another to the world, or roll the log back, and standalone receipt verification would never notice. The primitives (RFC 9162 consistency proofs) were already implemented and correct; this closes the loop on the AGENT side. - src/pacta/sthstore.py: a local STH pin store. Unknown log -> pin (trust-on-first-use, recorded as such). Same tree size -> the root must match the pin byte-for-byte; a mismatch is named EQUIVOCATION and is a hard rejection. Larger tree -> a consistency proof FROM THE PINNED SIZE is required and verified before the pin advances (receipts already embed a from-previous anchor; the anchor's root is itself checked against the pin so a lying anchor cannot bridge a split view). Smaller tree -> LOG ROLLBACK, hard rejection. - Freshness policy: --max-sth-age-seconds rejects stale (or future-dated) tree heads - an old-but-valid STH can hide later entries. - Wired into receipt-verify, claims, and agent (--sth-store, --consistency-proof, --max-sth-age-seconds); evidence records the pin action; any accountability failure fails the receipt closed. - Provider: log-consistency --from-size N (serve proofs for pinning agents whose pin is older than the receipt's embedded anchor) and log-audit (monitor self-check: recompute the tree, verify the stored STH and per-entry leaf hashes). Live drill in this commit's validation: pin-on-first-use -> matched -> grown-with-proof advance -> a real forged same-size split view REJECTED with the equivocation diagnostic -> freshness rejection -> clean self-audit. tests/test_sthstore.py covers pin/match/equivocation, growth-without-proof, lying consistency anchors, rollback, freshness. 45/45 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:08:34 +00:00
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