diff --git a/src/pacta/sthstore.py b/src/pacta/sthstore.py index 330c171..a122e38 100644 --- a/src/pacta/sthstore.py +++ b/src/pacta/sthstore.py @@ -85,6 +85,11 @@ def check_sth_against_store( "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", @@ -97,18 +102,52 @@ def check_sth_against_store( "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." + "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", ) @@ -133,11 +172,15 @@ def check_sth_against_store( 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}." + f"tree_size {pinned_size}. The pin is poisoned and the evidence retained." ], "rejected", ) @@ -174,6 +217,7 @@ def check_sth_against_store( "tree_size": tree_size, "root_hash": root_hash, "sth_timestamp": sth.get("timestamp"), + "sth": sth, "last_updated": stamp, } ) diff --git a/tests/test_sthstore.py b/tests/test_sthstore.py index 8a33d05..7f61f2b 100644 --- a/tests/test_sthstore.py +++ b/tests/test_sthstore.py @@ -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) 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) - 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):