mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
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>
259 lines
10 KiB
Python
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
|