proof-aware-crypto-tooling-.../src/pacta/sthstore.py
mrwulf 1a7d377c6b sthstore: equivocation poisons the pin + retains transferable evidence
Makes the paper's pin-store safety proposition true in the deployed code:
- the full signed STH is retained at pin and at each advance (not just
  size+root), so on equivocation both conflicting heads are transferable,
  signature-verifiable evidence;
- any equivocation (same-size root mismatch, or a consistency anchor
  disagreeing with the pin) persists poisoned=true with both signed
  heads and refuses ALL further operations for that log, restart-proof
  and independent of the update flag;
- rollback unchanged. 102 tests (was 100): poison persistence, evidence
  transferability, full-head retention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:53:13 +02:00

259 lines
10 KiB
Python

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"),
# The FULL signed head is retained, not just (size, root): if the
# log ever equivocates, the pinned head is one half of the
# transferable evidence pair (both are verifiable by anyone who
# holds the log's public key).
"sth": sth,
"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",
)
# A poisoned pin is terminal: once this log has been caught presenting a
# split view, no later head - however consistent-looking - is accepted.
# The retained evidence pair survives restarts and is transferable.
if pinned.get("poisoned"):
poisoned = pinned["poisoned"]
return SthCheckResult(
False,
[
f"POISONED: this log was caught equivocating at {poisoned.get('at')} "
f"({poisoned.get('reason')}). The conflicting signed heads are retained in the "
"pin store as transferable evidence. This log must never be trusted again; "
"manual removal of the store entry is the only (deliberate) way back."
],
"rejected",
)
pinned_size = int(pinned["tree_size"])
pinned_root = str(pinned["root_hash"])
def _poison(reason: str) -> None:
pinned["poisoned"] = {
"at": stamp,
"reason": reason,
# both halves of the evidence: the head we pinned, and the head
# that contradicts it - each independently signature-verifiable.
"evidence": {
"pinned_sth": pinned.get("sth"),
"conflicting_sth": sth,
},
}
save_store(store, store_path) # evidence retention must not depend on `update`
if tree_size == pinned_size:
if root_hash == pinned_root:
return SthCheckResult(True, [], "matched")
_poison(
f"different root hash at the pinned tree size {pinned_size} "
f"(pinned {pinned_root[:16]}…, presented {root_hash[:16]}…)"
)
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. Both signed "
"heads are retained in the pin store as transferable evidence; the pin is poisoned."
],
"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. If this receipt is simply STALE "
"(issued before the log grew), request a freshly issued receipt for the same "
"leaf - the provider's log-append is idempotent and re-issues an inclusion "
"proof against the current tree."
],
"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:
_poison(
f"consistency anchor disagrees with the pinned root at tree_size {pinned_size} "
f"(pinned {pinned_root[:16]}…, anchor {from_root[:16]}…)"
)
return SthCheckResult(
False,
[
"EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at "
f"tree_size {pinned_size}. The pin is poisoned and the evidence retained."
],
"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"),
"sth": sth,
"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