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>
2026-07-06 08:08:34 +00:00
|
|
|
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)
|
2026-07-09 15:53:13 +00:00
|
|
|
# A lying anchor IS equivocation evidence: the pin is now POISONED, so even
|
|
|
|
|
# a subsequently honest anchor must be refused (pin-store safety).
|
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>
2026-07-06 08:08:34 +00:00
|
|
|
honest_anchor = dict(lying_anchor, from_root_hash=old_root)
|
2026-07-09 15:53:13 +00:00
|
|
|
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"
|
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>
2026-07-06 08:08:34 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|