mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-04 20:03:40 +00:00
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>
This commit is contained in:
parent
caa864a749
commit
7c717d03fc
6 changed files with 456 additions and 0 deletions
|
|
@ -64,6 +64,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
log_append.add_argument("--out", required=True)
|
log_append.add_argument("--out", required=True)
|
||||||
log_append.set_defaults(func=cmd_log_append)
|
log_append.set_defaults(func=cmd_log_append)
|
||||||
|
|
||||||
|
log_consistency = sub.add_parser("log-consistency", help="Emit a consistency proof from an earlier tree size (for pinning agents).")
|
||||||
|
log_consistency.add_argument("--log-dir", required=True)
|
||||||
|
log_consistency.add_argument("--from-size", type=int, required=True)
|
||||||
|
log_consistency.add_argument("--out", help="Write the proof document here (default: stdout).")
|
||||||
|
log_consistency.set_defaults(func=cmd_log_consistency)
|
||||||
|
|
||||||
|
log_audit = sub.add_parser("log-audit", help="Monitor check: recompute the tree, verify the stored STH and append-only structure.")
|
||||||
|
log_audit.add_argument("--log-dir", required=True)
|
||||||
|
log_audit.set_defaults(func=cmd_log_audit)
|
||||||
|
|
||||||
log_sth = sub.add_parser("log-sth", help="Sign and print the latest transparency-log tree head.")
|
log_sth = sub.add_parser("log-sth", help="Sign and print the latest transparency-log tree head.")
|
||||||
log_sth.add_argument("--log-dir", default="provider/state/transparency-log")
|
log_sth.add_argument("--log-dir", default="provider/state/transparency-log")
|
||||||
log_sth.add_argument("--private-key", required=True)
|
log_sth.add_argument("--private-key", required=True)
|
||||||
|
|
@ -131,6 +141,36 @@ def cmd_log_append(args: argparse.Namespace) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_log_consistency(args) -> int:
|
||||||
|
from pacta.yamlio import dump_data
|
||||||
|
|
||||||
|
log = TransparencyLog(args.log_dir)
|
||||||
|
document = log.consistency_from(args.from_size)
|
||||||
|
if args.out:
|
||||||
|
dump_data(document, args.out)
|
||||||
|
print(f"consistency proof: {args.out}")
|
||||||
|
else:
|
||||||
|
for key in ("log_id", "from_tree_size", "from_root_hash", "to_tree_size", "to_root_hash"):
|
||||||
|
print(f"{key}: {document[key]}")
|
||||||
|
for item in document["proof"]:
|
||||||
|
print(f" {item}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_log_audit(args) -> int:
|
||||||
|
log = TransparencyLog(args.log_dir)
|
||||||
|
report = log.audit()
|
||||||
|
print(f"tree_size: {report['tree_size']}")
|
||||||
|
print(f"computed_root: {report['computed_root']}")
|
||||||
|
print(f"stored_sth_root: {report['stored_sth_root']}")
|
||||||
|
if report["problems"]:
|
||||||
|
print("problems:")
|
||||||
|
for problem in report["problems"]:
|
||||||
|
print(f" - {problem}")
|
||||||
|
print(f"ok: {str(report['ok']).lower()}")
|
||||||
|
return 0 if report["ok"] else 1
|
||||||
|
|
||||||
|
|
||||||
def cmd_log_sth(args: argparse.Namespace) -> int:
|
def cmd_log_sth(args: argparse.Namespace) -> int:
|
||||||
sth = TransparencyLog(args.log_dir).latest_sth(args.private_key, args.public_key)
|
sth = TransparencyLog(args.log_dir).latest_sth(args.private_key, args.public_key)
|
||||||
print(json.dumps(sth, indent=2, sort_keys=True))
|
print(json.dumps(sth, indent=2, sort_keys=True))
|
||||||
|
|
|
||||||
|
|
@ -174,5 +174,52 @@ class TransparencyLog:
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def consistency_from(self, old_tree_size: int) -> dict[str, Any]:
|
||||||
|
"""Consistency proof from an arbitrary earlier tree size - what a
|
||||||
|
pinning agent requests when its pin is older than the receipt's
|
||||||
|
embedded from_tree_size."""
|
||||||
|
entries = self.entries()
|
||||||
|
leaves = [entry.leaf_bytes() for entry in entries]
|
||||||
|
if old_tree_size < 0 or old_tree_size > len(leaves):
|
||||||
|
raise ValueError(f"old_tree_size {old_tree_size} outside tree size {len(leaves)}")
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"type": "pacta.transparency.consistency_proof.v1",
|
||||||
|
"log_id": self.metadata()["log_id"],
|
||||||
|
"from_tree_size": old_tree_size,
|
||||||
|
"from_root_hash": merkle_root(leaves[:old_tree_size]).hex(),
|
||||||
|
"to_tree_size": len(leaves),
|
||||||
|
"to_root_hash": merkle_root(leaves).hex(),
|
||||||
|
"proof": proof_to_hex(consistency_proof(leaves, old_tree_size)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def audit(self) -> dict[str, Any]:
|
||||||
|
"""Monitor-side self-check: recompute every prefix root, confirm the
|
||||||
|
stored STH matches the full tree, and confirm every prefix is
|
||||||
|
consistent with the final tree (append-only structure)."""
|
||||||
|
entries = self.entries()
|
||||||
|
leaves = [entry.leaf_bytes() for entry in entries]
|
||||||
|
problems: list[str] = []
|
||||||
|
for position, entry in enumerate(entries):
|
||||||
|
if entry.index != position:
|
||||||
|
problems.append(f"Entry at position {position} carries index {entry.index}.")
|
||||||
|
if leaf_hash(entry.leaf_bytes()).hex() != entry.leaf_hash:
|
||||||
|
problems.append(f"Entry {position} leaf_hash does not match its leaf bytes.")
|
||||||
|
computed_root = merkle_root(leaves).hex()
|
||||||
|
stored_sth = load_data(self.sth_path) if self.sth_path.exists() else None
|
||||||
|
if stored_sth:
|
||||||
|
if stored_sth.get("tree_size") != len(leaves):
|
||||||
|
problems.append("Stored STH tree_size does not match the entry count.")
|
||||||
|
if stored_sth.get("root_hash") != computed_root:
|
||||||
|
problems.append("Stored STH root hash does not match the recomputed tree root.")
|
||||||
|
return {
|
||||||
|
"tree_size": len(leaves),
|
||||||
|
"computed_root": computed_root,
|
||||||
|
"stored_sth_root": (stored_sth or {}).get("root_hash"),
|
||||||
|
"problems": problems,
|
||||||
|
"ok": not problems,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
from .config import RepoConfig
|
from .config import RepoConfig
|
||||||
from .profiles import get_profile
|
from .profiles import get_profile
|
||||||
from .signing import verify_attestation_signature
|
from .signing import verify_attestation_signature
|
||||||
|
from .sthstore import check_sth_against_store, check_sth_freshness
|
||||||
from .transparency import load_receipt, verify_receipt
|
from .transparency import load_receipt, verify_receipt
|
||||||
from .yamlio import load_data
|
from .yamlio import load_data
|
||||||
|
|
||||||
|
|
@ -41,6 +42,9 @@ def validate_attestation(
|
||||||
transparency_log_public_key_path: str | Path | None = None,
|
transparency_log_public_key_path: str | Path | None = None,
|
||||||
require_transparency_signatures: str = "ed25519",
|
require_transparency_signatures: str = "ed25519",
|
||||||
require_transparency_receipt: bool = False,
|
require_transparency_receipt: bool = False,
|
||||||
|
sth_store_path: str | Path | None = None,
|
||||||
|
consistency_proof_path: str | Path | None = None,
|
||||||
|
max_sth_age_seconds: int | None = None,
|
||||||
) -> AttestationResult:
|
) -> AttestationResult:
|
||||||
provider = raw.get("provider")
|
provider = raw.get("provider")
|
||||||
subject = raw.get("subject") or {}
|
subject = raw.get("subject") or {}
|
||||||
|
|
@ -112,6 +116,25 @@ def validate_attestation(
|
||||||
transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path)
|
transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path)
|
||||||
if not receipt_result.accepted:
|
if not receipt_result.accepted:
|
||||||
diagnostics.extend(receipt_result.diagnostics)
|
diagnostics.extend(receipt_result.diagnostics)
|
||||||
|
sth = receipt.get("sth") or {}
|
||||||
|
if max_sth_age_seconds is not None:
|
||||||
|
fresh, error = check_sth_freshness(sth, int(max_sth_age_seconds))
|
||||||
|
if not fresh:
|
||||||
|
diagnostics.append(error or "Signed tree head fails the freshness policy.")
|
||||||
|
if sth_store_path:
|
||||||
|
proof_hex = None
|
||||||
|
if consistency_proof_path:
|
||||||
|
raw_proof = load_data(consistency_proof_path)
|
||||||
|
proof_hex = [str(item) for item in (raw_proof.get("proof") if isinstance(raw_proof, dict) else raw_proof) or []]
|
||||||
|
sth_check = check_sth_against_store(
|
||||||
|
sth,
|
||||||
|
sth_store_path,
|
||||||
|
consistency_proof_hex=proof_hex,
|
||||||
|
consistency_from=receipt.get("consistency"),
|
||||||
|
)
|
||||||
|
transparency_evidence.update(sth_check.evidence())
|
||||||
|
if not sth_check.ok:
|
||||||
|
diagnostics.extend("STH store: " + note for note in sth_check.diagnostics)
|
||||||
|
|
||||||
accepted = not diagnostics
|
accepted = not diagnostics
|
||||||
evidence = {
|
evidence = {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from .profiles import get_profile
|
||||||
from .repo import clone_or_fetch, status_for
|
from .repo import clone_or_fetch, status_for
|
||||||
from .report import render_markdown
|
from .report import render_markdown
|
||||||
from .risk import score_claim_card
|
from .risk import score_claim_card
|
||||||
|
from .sthstore import check_sth_against_store, check_sth_freshness
|
||||||
from .transparency import load_receipt, verify_receipt
|
from .transparency import load_receipt, verify_receipt
|
||||||
from .yamlio import dump_data, load_data
|
from .yamlio import dump_data, load_data
|
||||||
|
|
||||||
|
|
@ -111,6 +112,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
claims.add_argument("--transparency-log-public-key")
|
claims.add_argument("--transparency-log-public-key")
|
||||||
claims.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
|
claims.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
|
||||||
claims.add_argument("--require-transparency-receipt", action="store_true")
|
claims.add_argument("--require-transparency-receipt", action="store_true")
|
||||||
|
claims.add_argument("--sth-store")
|
||||||
|
claims.add_argument("--consistency-proof")
|
||||||
|
claims.add_argument("--max-sth-age-seconds", type=int)
|
||||||
claims.set_defaults(func=cmd_claims)
|
claims.set_defaults(func=cmd_claims)
|
||||||
|
|
||||||
report = sub.add_parser("report", help="Generate a human-readable Markdown risk report.")
|
report = sub.add_parser("report", help="Generate a human-readable Markdown risk report.")
|
||||||
|
|
@ -132,6 +136,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
receipt_verify.add_argument("--receipt", required=True)
|
receipt_verify.add_argument("--receipt", required=True)
|
||||||
receipt_verify.add_argument("--log-public-key", required=True)
|
receipt_verify.add_argument("--log-public-key", required=True)
|
||||||
receipt_verify.add_argument("--require-signatures", choices=["ed25519", "both"], default="ed25519")
|
receipt_verify.add_argument("--require-signatures", choices=["ed25519", "both"], default="ed25519")
|
||||||
|
receipt_verify.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).")
|
||||||
|
receipt_verify.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size (provider: log-consistency).")
|
||||||
|
receipt_verify.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this (freshness policy).")
|
||||||
receipt_verify.set_defaults(func=cmd_receipt_verify)
|
receipt_verify.set_defaults(func=cmd_receipt_verify)
|
||||||
|
|
||||||
agent = sub.add_parser("agent", help="Apply a policy-gated consequence to verification evidence.")
|
agent = sub.add_parser("agent", help="Apply a policy-gated consequence to verification evidence.")
|
||||||
|
|
@ -159,6 +166,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
agent.add_argument("--transparency-log-public-key")
|
agent.add_argument("--transparency-log-public-key")
|
||||||
agent.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
|
agent.add_argument("--require-transparency-signatures", choices=["ed25519", "both"], default="ed25519")
|
||||||
agent.add_argument("--require-transparency-receipt", action="store_true")
|
agent.add_argument("--require-transparency-receipt", action="store_true")
|
||||||
|
agent.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).")
|
||||||
|
agent.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size.")
|
||||||
|
agent.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this.")
|
||||||
agent.set_defaults(func=cmd_agent)
|
agent.set_defaults(func=cmd_agent)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
@ -369,6 +379,15 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int:
|
||||||
attestation = load_attestation(args.attestation)
|
attestation = load_attestation(args.attestation)
|
||||||
receipt = load_receipt(args.receipt)
|
receipt = load_receipt(args.receipt)
|
||||||
result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures)
|
result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures)
|
||||||
|
accountability_diagnostics = _log_accountability_checks(
|
||||||
|
receipt,
|
||||||
|
sth_store=args.sth_store,
|
||||||
|
consistency_proof_path=args.consistency_proof,
|
||||||
|
max_sth_age_seconds=args.max_sth_age_seconds,
|
||||||
|
)
|
||||||
|
if accountability_diagnostics:
|
||||||
|
result.accepted = False
|
||||||
|
result.diagnostics.extend(accountability_diagnostics)
|
||||||
print(f"accepted: {str(result.accepted).lower()}")
|
print(f"accepted: {str(result.accepted).lower()}")
|
||||||
print(f"log_id: {result.log_id or 'unknown'}")
|
print(f"log_id: {result.log_id or 'unknown'}")
|
||||||
print(f"tree_size: {result.tree_size if result.tree_size is not None else 'unknown'}")
|
print(f"tree_size: {result.tree_size if result.tree_size is not None else 'unknown'}")
|
||||||
|
|
@ -383,6 +402,40 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int:
|
||||||
return 0 if result.accepted else 1
|
return 0 if result.accepted else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _log_accountability_checks(
|
||||||
|
receipt: dict,
|
||||||
|
sth_store: str | None,
|
||||||
|
consistency_proof_path: str | None,
|
||||||
|
max_sth_age_seconds: int | None,
|
||||||
|
) -> list[str]:
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
sth = receipt.get("sth") or {}
|
||||||
|
if max_sth_age_seconds is not None:
|
||||||
|
fresh, error = check_sth_freshness(sth, max_sth_age_seconds)
|
||||||
|
if not fresh:
|
||||||
|
diagnostics.append(error or "Signed tree head fails the freshness policy.")
|
||||||
|
if sth_store:
|
||||||
|
proof_hex = None
|
||||||
|
if consistency_proof_path:
|
||||||
|
from .yamlio import load_data
|
||||||
|
|
||||||
|
raw = load_data(consistency_proof_path)
|
||||||
|
proof_hex = [str(item) for item in (raw.get("proof") if isinstance(raw, dict) else raw) or []]
|
||||||
|
check = check_sth_against_store(
|
||||||
|
sth,
|
||||||
|
sth_store,
|
||||||
|
consistency_proof_hex=proof_hex,
|
||||||
|
consistency_from=receipt.get("consistency"),
|
||||||
|
)
|
||||||
|
for note in check.diagnostics:
|
||||||
|
prefix = "" if check.ok else "STH store: "
|
||||||
|
if check.ok:
|
||||||
|
print(f"sth-store: {note}")
|
||||||
|
else:
|
||||||
|
diagnostics.append(prefix + note)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
def cmd_agent(args: argparse.Namespace) -> int:
|
def cmd_agent(args: argparse.Namespace) -> int:
|
||||||
card = _card_for_agent(args)
|
card = _card_for_agent(args)
|
||||||
decision = run_agent_action(
|
decision = run_agent_action(
|
||||||
|
|
@ -497,4 +550,7 @@ def _attestation_for_args(args: argparse.Namespace, repo: RepoConfig):
|
||||||
transparency_log_public_key_path=getattr(args, "transparency_log_public_key", None),
|
transparency_log_public_key_path=getattr(args, "transparency_log_public_key", None),
|
||||||
require_transparency_signatures=str(getattr(args, "require_transparency_signatures", "ed25519")),
|
require_transparency_signatures=str(getattr(args, "require_transparency_signatures", "ed25519")),
|
||||||
require_transparency_receipt=bool(getattr(args, "require_transparency_receipt", False)),
|
require_transparency_receipt=bool(getattr(args, "require_transparency_receipt", False)),
|
||||||
|
sth_store_path=getattr(args, "sth_store", None),
|
||||||
|
consistency_proof_path=getattr(args, "consistency_proof", None),
|
||||||
|
max_sth_age_seconds=getattr(args, "max_sth_age_seconds", None),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
212
src/pacta/sthstore.py
Normal file
212
src/pacta/sthstore.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
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"),
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
|
||||||
|
pinned_size = int(pinned["tree_size"])
|
||||||
|
pinned_root = str(pinned["root_hash"])
|
||||||
|
|
||||||
|
if tree_size == pinned_size:
|
||||||
|
if root_hash == pinned_root:
|
||||||
|
return SthCheckResult(True, [], "matched")
|
||||||
|
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."
|
||||||
|
],
|
||||||
|
"rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
if tree_size < pinned_size:
|
||||||
|
return SthCheckResult(
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
f"LOG ROLLBACK: presented tree_size {tree_size} is smaller than the pinned size "
|
||||||
|
f"{pinned_size}. Append-only logs never shrink."
|
||||||
|
],
|
||||||
|
"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:
|
||||||
|
return SthCheckResult(
|
||||||
|
False,
|
||||||
|
[
|
||||||
|
"EQUIVOCATION: the receipt's consistency anchor disagrees with the pinned root at "
|
||||||
|
f"tree_size {pinned_size}."
|
||||||
|
],
|
||||||
|
"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"),
|
||||||
|
"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
|
||||||
78
tests/test_sthstore.py
Normal file
78
tests/test_sthstore.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Reference in a new issue