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>
This commit is contained in:
mrwulf 2026-07-06 10:08:34 +02:00
parent caa864a749
commit 7c717d03fc
6 changed files with 456 additions and 0 deletions

View file

@ -64,6 +64,16 @@ def build_parser() -> argparse.ArgumentParser:
log_append.add_argument("--out", required=True) log_append.add_argument("--out", required=True)
log_append.set_defaults(func=cmd_log_append) 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 = 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("--log-dir", default="provider/state/transparency-log")
log_sth.add_argument("--private-key", required=True) log_sth.add_argument("--private-key", required=True)
@ -131,6 +141,36 @@ def cmd_log_append(args: argparse.Namespace) -> int:
return 0 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: def cmd_log_sth(args: argparse.Namespace) -> int:
sth = TransparencyLog(args.log_dir).latest_sth(args.private_key, args.public_key) sth = TransparencyLog(args.log_dir).latest_sth(args.private_key, args.public_key)
print(json.dumps(sth, indent=2, sort_keys=True)) print(json.dumps(sth, indent=2, sort_keys=True))

View file

@ -174,5 +174,52 @@ class TransparencyLog:
return receipt 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: def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

View file

@ -7,6 +7,7 @@ from typing import Any
from .config import RepoConfig from .config import RepoConfig
from .profiles import get_profile from .profiles import get_profile
from .signing import verify_attestation_signature from .signing import verify_attestation_signature
from .sthstore import check_sth_against_store, check_sth_freshness
from .transparency import load_receipt, verify_receipt from .transparency import load_receipt, verify_receipt
from .yamlio import load_data from .yamlio import load_data
@ -41,6 +42,9 @@ def validate_attestation(
transparency_log_public_key_path: str | Path | None = None, transparency_log_public_key_path: str | Path | None = None,
require_transparency_signatures: str = "ed25519", require_transparency_signatures: str = "ed25519",
require_transparency_receipt: bool = False, 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: ) -> AttestationResult:
provider = raw.get("provider") provider = raw.get("provider")
subject = raw.get("subject") or {} subject = raw.get("subject") or {}
@ -112,6 +116,25 @@ def validate_attestation(
transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path) transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path)
if not receipt_result.accepted: if not receipt_result.accepted:
diagnostics.extend(receipt_result.diagnostics) 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 accepted = not diagnostics
evidence = { evidence = {

View file

@ -24,6 +24,7 @@ from .profiles import get_profile
from .repo import clone_or_fetch, status_for from .repo import clone_or_fetch, status_for
from .report import render_markdown from .report import render_markdown
from .risk import score_claim_card from .risk import score_claim_card
from .sthstore import check_sth_against_store, check_sth_freshness
from .transparency import load_receipt, verify_receipt from .transparency import load_receipt, verify_receipt
from .yamlio import dump_data, load_data 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("--transparency-log-public-key")
claims.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519") claims.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
claims.add_argument("--require-transparency-receipt", action="store_true") 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) claims.set_defaults(func=cmd_claims)
report = sub.add_parser("report", help="Generate a human-readable Markdown risk report.") 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("--receipt", required=True)
receipt_verify.add_argument("--log-public-key", 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("--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) receipt_verify.set_defaults(func=cmd_receipt_verify)
agent = sub.add_parser("agent", help="Apply a policy-gated consequence to verification evidence.") 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("--transparency-log-public-key")
agent.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519") agent.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
agent.add_argument("--require-transparency-receipt", action="store_true") 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) agent.set_defaults(func=cmd_agent)
return parser return parser
@ -369,6 +379,15 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int:
attestation = load_attestation(args.attestation) attestation = load_attestation(args.attestation)
receipt = load_receipt(args.receipt) receipt = load_receipt(args.receipt)
result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures) 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"accepted: {str(result.accepted).lower()}")
print(f"log_id: {result.log_id or 'unknown'}") 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'}") 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 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: def cmd_agent(args: argparse.Namespace) -> int:
card = _card_for_agent(args) card = _card_for_agent(args)
decision = run_agent_action( 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), transparency_log_public_key_path=getattr(args, "transparency_log_public_key", None),
require_transparency_signatures=str(getattr(args, "require_transparency_signatures", "ed25519")), require_transparency_signatures=str(getattr(args, "require_transparency_signatures", "ed25519")),
require_transparency_receipt=bool(getattr(args, "require_transparency_receipt", False)), 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),
) )

212
src/pacta/sthstore.py Normal file
View file

@ -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

78
tests/test_sthstore.py Normal file
View file

@ -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