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 __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"),
|
2026-07-09 15:53:13 +00:00
|
|
|
# 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,
|
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
|
|
|
"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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-09 15:53:13 +00:00
|
|
|
# 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",
|
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
pinned_size = int(pinned["tree_size"])
|
|
|
|
|
pinned_root = str(pinned["root_hash"])
|
|
|
|
|
|
2026-07-09 15:53:13 +00:00
|
|
|
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`
|
|
|
|
|
|
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
|
|
|
if tree_size == pinned_size:
|
|
|
|
|
if root_hash == pinned_root:
|
|
|
|
|
return SthCheckResult(True, [], "matched")
|
2026-07-09 15:53:13 +00:00
|
|
|
_poison(
|
|
|
|
|
f"different root hash at the pinned tree size {pinned_size} "
|
|
|
|
|
f"(pinned {pinned_root[:16]}…, presented {root_hash[:16]}…)"
|
|
|
|
|
)
|
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
|
|
|
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]}…). "
|
2026-07-09 15:53:13 +00:00
|
|
|
"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."
|
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
|
|
|
],
|
|
|
|
|
"rejected",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tree_size < pinned_size:
|
|
|
|
|
return SthCheckResult(
|
|
|
|
|
False,
|
|
|
|
|
[
|
|
|
|
|
f"LOG ROLLBACK: presented tree_size {tree_size} is smaller than the pinned size "
|
REAL EVIDENCE: guarded replay of all four repos, attested, logged, dogfooded
The provider ran its full honest replay against the four verified
repositories on this machine - every Lean compile and axiom audit routed
through lean-guard (memory-capped, core-pinned, single-flight, ~30 min
per fork) - and the results are now shipped under evidence/:
- 16/16 certificates proven per fork, every axiom cone boundary-exact
(the four apex tiers carry their fork's documented SHA-512/wire
boundary axiom-for-axiom), each attestation pinned to the exact repo
commit (dalek 8ded7bc, anza 673c15e, risc0 98a13a6, betrusted
81f614a) and Ed25519-signed.
- All four appended to the persistent transparency log. The log holds
EIGHT leaves: the first four are the initial run's attestations,
which honestly recorded an AUDIT FAILURE (the two pacta bugs fixed in
e87f0e8) - an append-only trust ledger keeps its bad day, and the
fixed run's leaves sit beside it.
- Every receipt re-verified through the FULL stack: dogfood verifier
(backend verified-dalek-serial recorded), STH pin store, freshness
policy. Receipts are freshly issued against the final tree (a stale
mid-run receipt tripped the pin store's rollback defense exactly as
designed; the rollback diagnostic now hints at idempotent re-issue).
- The capstone consequence ran for real: pacta agent with trusted
provider + signature via the proven path + required receipt + pin
store + --require-verified-verifier built the R4-gated library
capsule from ATTESTED evidence (no local Lean replay needed by the
consuming agent).
Docs and teaching updated against the real artifacts: evidence/README
(inventory + re-verify instructions), README "Real Evidence" section,
lecture 5 now re-derives 16/16 verdicts from the REAL dalek attestation
(signature checked on the proven path, provider labels ignored), and
lecture 6 verifies all four REAL receipts and walks a fresh pin store
over them. Every changed notebook cell executed before commit. 49/49
tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:54:48 +00:00
|
|
|
f"{pinned_size}. Append-only logs never shrink. If this receipt is simply STALE "
|
|
|
|
|
"(issued before the log grew), request a freshly issued receipt for the same "
|
|
|
|
|
"leaf - the provider's log-append is idempotent and re-issues an inclusion "
|
|
|
|
|
"proof against the current tree."
|
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
|
|
|
],
|
|
|
|
|
"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:
|
2026-07-09 15:53:13 +00:00
|
|
|
_poison(
|
|
|
|
|
f"consistency anchor disagrees with the pinned root at tree_size {pinned_size} "
|
|
|
|
|
f"(pinned {pinned_root[:16]}…, anchor {from_root[:16]}…)"
|
|
|
|
|
)
|
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
|
|
|
return SthCheckResult(
|
|
|
|
|
False,
|
|
|
|
|
[
|
|
|
|
|
"EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at "
|
2026-07-09 15:53:13 +00:00
|
|
|
f"tree_size {pinned_size}. The pin is poisoned and the evidence retained."
|
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
|
|
|
],
|
|
|
|
|
"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"),
|
2026-07-09 15:53:13 +00:00
|
|
|
"sth": sth,
|
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
|
|
|
"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
|