mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
Two Fable-5 inventory agents cross-checked every empirical claim in the paper against code/deployed log, and every external pointer against the live internet. Fixes on both sides: CODE (system brought up to the paper's claims): - SECURITY: pin-store mutation (incl. permanent poisoning) was reachable via receipts whose head signature FAILED verification in two of three consumer paths (attestation.py, cli.py) - an unauthenticated forged head at the pinned size could poison a consumer's pin forever and pollute the equivocation-evidence pair with an unverifiable head, contradicting SS5.4's 'validly signed' precondition and Prop 1. Both paths now gate the store on a verified Ed25519 head signature (logclient.py already did). Regression test added. - Prop 2 made literally true: _normalize_certificate now derives the cleanliness verdict purely from (observed cone, local allowed set) in EVERY branch; the operator's axiom_status label is never copied (was passed through for non-proven certs), missing cone => unverifiable always. Labels can deny, never grant. Test added. - webdocs: '/v1/sth-history: every head ever signed' -> 'the published head history'. PAPER (claims brought down to reality): - 'every head ever signed' -> the signed head history since publication began (heads for sizes 1-7 predate the mirror and were not retained). - Run-3 bullet: 'independently checkable by diffing the two commit trees' was no longer reproducible (pre-rewrite objects discarded); now states the log-internal corroboration (identical cert lists and cones across leaves 4-7 vs 8-11) and that tree diffs are not public. - Appendix A leaf block now actually verbatim: scheme openssl-ed25519, verified_backend serial/u64, real Lean version (4.30.0-rc2) instead of 4.x.y placeholder, leaf's actual axiom order (finalize/new/update), machine_protection note quoted, elisions marked; preamble wording matches. - Appendix C upstream boundary reordered to check.sh's verbatim order. - '27 lines - all annotation' -> honest description (axiom-list entries + operation reordering from one fork's black_box barrier). - Prop 2 proof + App A: status label consulted only negatively. - SS7: provenance fields noted as outside the signed payload; consumer chain relies on none of them. - Bibliography: all 20 entries verified against DBLP/RFC-editor - zero errors; added missing page numbers to 6 entries; thebibliography width 19->20. All URLs verified public; no PlanetMacro leakage. 17 pages, 106 tests green, accumulator untouched (tree_size 12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
221 lines
11 KiB
Python
221 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .config import RepoConfig
|
|
from .profiles import get_profile
|
|
from .signing import verify_attestation_signature_detailed
|
|
from .sthstore import check_sth_against_store, check_sth_freshness
|
|
from .transparency import load_receipt, verify_receipt
|
|
from .yamlio import load_data
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AttestationResult:
|
|
accepted: bool
|
|
provider: str | None
|
|
diagnostics: list[str] = field(default_factory=list)
|
|
certificates: list[dict[str, Any]] = field(default_factory=list)
|
|
evidence: dict[str, Any] = field(default_factory=dict)
|
|
trusted_base: list[str] = field(default_factory=list)
|
|
repo_commit: str | None = None
|
|
coverage_warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
def load_attestation(path: str | Path) -> dict[str, Any]:
|
|
raw = load_data(path)
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"Attestation must be a mapping: {path}")
|
|
return raw
|
|
|
|
|
|
def validate_attestation(
|
|
raw: dict[str, Any],
|
|
repo: RepoConfig,
|
|
path: str | Path | None = None,
|
|
trusted_provider: str | None = None,
|
|
public_key_path: str | Path | None = None,
|
|
allow_unsigned: bool = False,
|
|
transparency_receipt_path: str | Path | None = None,
|
|
transparency_log_public_key_path: str | Path | None = None,
|
|
require_transparency_signatures: str = "ed25519",
|
|
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,
|
|
require_verified_verifier: bool = False,
|
|
) -> AttestationResult:
|
|
provider = raw.get("provider")
|
|
subject = raw.get("subject") or {}
|
|
diagnostics: list[str] = []
|
|
if not provider:
|
|
diagnostics.append("Attestation is missing provider.")
|
|
if trusted_provider is None:
|
|
diagnostics.append("No trusted attestation provider was explicitly configured.")
|
|
elif provider != trusted_provider:
|
|
diagnostics.append(f"Attestation provider '{provider}' does not match trusted provider '{trusted_provider}'.")
|
|
if subject.get("component") and subject.get("component") != repo.name:
|
|
diagnostics.append(f"Attestation subject component '{subject.get('component')}' does not match repo '{repo.name}'.")
|
|
if repo.url and subject.get("repo_url") and subject.get("repo_url") != repo.url:
|
|
diagnostics.append("Attestation subject repo_url does not match config.")
|
|
if subject.get("verification_dir") and subject.get("verification_dir") != repo.verification_dir:
|
|
diagnostics.append("Attestation subject verification_dir does not match config.")
|
|
|
|
certs = raw.get("certificates") or []
|
|
if not isinstance(certs, list) or not certs:
|
|
diagnostics.append("Attestation contains no certificate results.")
|
|
certs = []
|
|
profile = get_profile(repo.kind, repo)
|
|
expected_names = set(repo.certificates or profile.default_certificates)
|
|
observed_names = {str(cert.get("name")) for cert in certs if isinstance(cert, dict)}
|
|
missing = sorted(expected_names - observed_names)
|
|
coverage_warnings: list[str] = []
|
|
if missing:
|
|
# Partial coverage is NOT a rejection: the uncovered certificates
|
|
# simply stay unproven in the claim card and the risk score degrades
|
|
# accordingly (e.g. an arithmetic-only attestation caps at R3).
|
|
coverage_warnings.append(
|
|
"Attestation does not cover configured certificate(s): " + ", ".join(missing)
|
|
)
|
|
|
|
signature = raw.get("signature") or {}
|
|
environment = raw.get("environment") or {}
|
|
signature_status = signature.get("status", "not_checked")
|
|
signature_backend = "none"
|
|
if public_key_path:
|
|
ok, error, signature_backend = verify_attestation_signature_detailed(raw, public_key_path)
|
|
if ok:
|
|
signature_status = "verified"
|
|
else:
|
|
diagnostics.append(f"Attestation signature verification failed: {error}")
|
|
if require_verified_verifier and signature_backend != "verified-dalek-serial":
|
|
diagnostics.append(
|
|
"Policy requires the dogfood (certificate-covered) Ed25519 verifier, but verification ran on "
|
|
f"backend '{signature_backend}'. Build it with: pacta dogfood-build --source <pinned-workspace>."
|
|
)
|
|
elif signature_status == "signed":
|
|
diagnostics.append("Signed attestation requires --attestation-public-key.")
|
|
elif signature_status == "not_implemented":
|
|
if allow_unsigned:
|
|
signature_status = "not_implemented"
|
|
else:
|
|
diagnostics.append("Unsigned attestation requires --allow-unsigned-attestation.")
|
|
elif signature_status != "verified":
|
|
diagnostics.append(f"Attestation signature status is not acceptable: {signature_status}")
|
|
|
|
transparency_evidence: dict[str, Any] = {}
|
|
if require_transparency_receipt and not transparency_receipt_path:
|
|
diagnostics.append("Transparency receipt is required by policy but was not supplied.")
|
|
if transparency_receipt_path:
|
|
if not transparency_log_public_key_path:
|
|
diagnostics.append("Transparency receipt verification requires --transparency-log-public-key.")
|
|
else:
|
|
receipt = load_receipt(transparency_receipt_path)
|
|
receipt_result = verify_receipt(
|
|
raw,
|
|
receipt,
|
|
transparency_log_public_key_path,
|
|
require_signatures=require_transparency_signatures,
|
|
)
|
|
transparency_evidence = receipt_result.evidence()
|
|
transparency_evidence["transparency_receipt_path"] = str(transparency_receipt_path)
|
|
if not receipt_result.accepted:
|
|
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:
|
|
# The pin-store state machine (including permanent poisoning
|
|
# on equivocation) only ever runs on a VALIDLY SIGNED head;
|
|
# an unauthenticated head must not be able to mutate - let
|
|
# alone poison - the consumer's pin.
|
|
if receipt_result.signatures.get("ed25519") != "verified":
|
|
transparency_evidence["sth_store"] = "skipped_unverified_head"
|
|
diagnostics.append(
|
|
"STH store: head signature did not verify; pin store not consulted or updated."
|
|
)
|
|
else:
|
|
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
|
|
evidence = {
|
|
"evidence_mode": "third_party_attestation",
|
|
"attestation_provider": provider,
|
|
"attestation_path": str(path) if path else None,
|
|
"attestation_signature_status": signature_status,
|
|
"attestation_signature_backend": signature_backend,
|
|
"attestation_log_url": raw.get("log_url") or signature.get("log_url"),
|
|
"attestation_issued_at": raw.get("issued_at"),
|
|
"check_log_path": (raw.get("replay") or {}).get("check_log_path"),
|
|
"axiom_log_path": (raw.get("replay") or {}).get("axiom_log_path"),
|
|
"lean_version": environment.get("lean_version"),
|
|
"lake_version": environment.get("lake_version"),
|
|
"attestation_coverage_warnings": coverage_warnings,
|
|
**transparency_evidence,
|
|
}
|
|
trusted_base = []
|
|
if accepted:
|
|
trusted_base.append(f"Third-party proof-checking attestation provider: {provider}.")
|
|
trusted_base.append("Provider environment, replay implementation, signing key custody, and log retention.")
|
|
if transparency_receipt_path:
|
|
trusted_base.append("Transparency log append-only behavior, signed tree head key custody, and monitor/auditor availability.")
|
|
return AttestationResult(
|
|
accepted=accepted,
|
|
provider=str(provider) if provider else None,
|
|
diagnostics=diagnostics,
|
|
certificates=[_normalize_certificate(cert, profile) for cert in certs if isinstance(cert, dict)],
|
|
evidence=evidence,
|
|
trusted_base=trusted_base,
|
|
repo_commit=subject.get("repo_commit"),
|
|
coverage_warnings=coverage_warnings,
|
|
)
|
|
|
|
|
|
def _normalize_certificate(cert: dict[str, Any], profile: Any) -> dict[str, Any]:
|
|
"""Normalize a provider-reported certificate against LOCAL policy.
|
|
|
|
The provider is trusted for its OBSERVATION (which axioms #print axioms
|
|
reported); it is never trusted for the VERDICT. axiom_status is re-derived
|
|
here by comparing the observed axioms against this agent's own allowed
|
|
set for the certificate - a provider that labels a dirty cone "clean"
|
|
gains nothing.
|
|
"""
|
|
name = str(cert.get("name") or "")
|
|
status = str(cert.get("status") or "unknown")
|
|
observed = [str(a) for a in (cert.get("observed_axioms") or [])]
|
|
expected = profile.expected_axioms_for(name)
|
|
# The cleanliness verdict is a function of (observed cone, local allowed
|
|
# set) ONLY - in every branch. The provider's status label still gates
|
|
# acceptance elsewhere (only status=="proven" certificates can count),
|
|
# but it can only deny, never grant, and the provider's own axiom_status
|
|
# label is never copied into the verdict.
|
|
if observed:
|
|
axiom_status = "clean" if sorted(observed) == sorted(expected) else "dirty"
|
|
else:
|
|
# no observed cone: nothing to re-derive from; distrust.
|
|
axiom_status = "unverifiable"
|
|
provider_verdict = str(cert.get("axiom_status") or "not_stated")
|
|
return {
|
|
"name": name,
|
|
"status": status,
|
|
"axiom_status": axiom_status,
|
|
"observed_axioms": observed,
|
|
"expected_axioms": list(expected),
|
|
"provider_axiom_verdict": provider_verdict,
|
|
}
|