mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-04 20:03:40 +00:00
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:
parent
2f67f14054
commit
1a7d377c6b
2 changed files with 87 additions and 3 deletions
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue