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>
This commit is contained in:
mrwulf 2026-07-09 17:53:13 +02:00
parent 2f67f14054
commit 1a7d377c6b
2 changed files with 87 additions and 3 deletions

View file

@ -85,6 +85,11 @@ def check_sth_against_store(
"tree_size": tree_size, "tree_size": tree_size,
"root_hash": root_hash, "root_hash": root_hash,
"sth_timestamp": sth.get("timestamp"), "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, "first_seen": stamp,
"last_updated": stamp, "last_updated": stamp,
"trust_origin": "trust_on_first_use", "trust_origin": "trust_on_first_use",
@ -97,18 +102,52 @@ def check_sth_against_store(
"pinned_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_size = int(pinned["tree_size"])
pinned_root = str(pinned["root_hash"]) 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 tree_size == pinned_size:
if root_hash == pinned_root: if root_hash == pinned_root:
return SthCheckResult(True, [], "matched") 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( return SthCheckResult(
False, False,
[ [
"EQUIVOCATION: the log presented a different root hash at the pinned tree size " "EQUIVOCATION: the log presented a different root hash at the pinned tree size "
f"{pinned_size} (pinned {pinned_root[:16]}…, presented {root_hash[:16]}…). " f"{pinned_size} (pinned {pinned_root[:16]}…, presented {root_hash[:16]}…). "
"This log is maintaining a split view and must not be trusted again." "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", "rejected",
) )
@ -133,11 +172,15 @@ def check_sth_against_store(
from_root = str(consistency_from.get("from_root_hash") or "") from_root = str(consistency_from.get("from_root_hash") or "")
if from_size == pinned_size: if from_size == pinned_size:
if from_root != pinned_root: 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( return SthCheckResult(
False, False,
[ [
"EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at " "EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at "
f"tree_size {pinned_size}." f"tree_size {pinned_size}. The pin is poisoned and the evidence retained."
], ],
"rejected", "rejected",
) )
@ -174,6 +217,7 @@ def check_sth_against_store(
"tree_size": tree_size, "tree_size": tree_size,
"root_hash": root_hash, "root_hash": root_hash,
"sth_timestamp": sth.get("timestamp"), "sth_timestamp": sth.get("timestamp"),
"sth": sth,
"last_updated": stamp, "last_updated": stamp,
} }
) )

View file

@ -55,8 +55,48 @@ def test_receipt_embedded_consistency_anchor_is_checked(tmp_path):
} }
out = check_sth_against_store(_sth(4, new_root), store, consistency_from=lying_anchor) 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) assert not out.ok and any("EQUIVOCATION" in d for d in out.diagnostics)
# A lying anchor IS equivocation evidence: the pin is now POISONED, so even
# a subsequently honest anchor must be refused (pin-store safety).
honest_anchor = dict(lying_anchor, from_root_hash=old_root) 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 after = check_sth_against_store(_sth(4, new_root), store, consistency_from=honest_anchor)
assert not after.ok and any("POISONED" in d for d in after.diagnostics)
def test_equivocation_poisons_and_retains_transferable_evidence(tmp_path):
import json
store = tmp_path / "sth.json"
root3 = merkle_root(_tree(3)).hex()
evil_root = merkle_root(_tree(9)).hex()
assert check_sth_against_store(_sth(3, root3), store).ok
evil = check_sth_against_store(_sth(3, evil_root), store)
assert not evil.ok and any("evidence" in d for d in evil.diagnostics)
# the poison and BOTH signed heads survive on disk, restart-proof
persisted = json.loads(store.read_text())["logs"]["log-1"]
poison = persisted["poisoned"]
assert poison["evidence"]["pinned_sth"]["root_hash"] == root3
assert poison["evidence"]["conflicting_sth"]["root_hash"] == evil_root
# nothing rehabilitates the log: not even the originally pinned head
again = check_sth_against_store(_sth(3, root3), store)
assert not again.ok and any("POISONED" in d for d in again.diagnostics)
# ...and growth with a valid proof is refused too
leaves = _tree(5)
grown = check_sth_against_store(
_sth(5, merkle_root(leaves).hex()), store,
consistency_proof_hex=proof_to_hex(consistency_proof(leaves, 3)),
)
assert not grown.ok and any("POISONED" in d for d in grown.diagnostics)
def test_pin_retains_full_signed_head(tmp_path):
import json
store = tmp_path / "sth.json"
sth = _sth(3, merkle_root(_tree(3)).hex())
sth["signatures"] = {"ed25519": {"status": "signed", "signature_base64": "AAAA"}}
assert check_sth_against_store(sth, store).ok
pinned = json.loads(store.read_text())["logs"]["log-1"]
assert pinned["sth"]["signatures"]["ed25519"]["signature_base64"] == "AAAA"
def test_rollback_is_rejected(tmp_path): def test_rollback_is_rejected(tmp_path):