audit v3: paper-reality congruence + external-pointer integrity (Fable-5 Socratic pass)

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>
This commit is contained in:
mrwulf 2026-07-09 21:33:28 +02:00
parent 78eea14512
commit 2dae2ca0db
7 changed files with 179 additions and 55 deletions

Binary file not shown.

View file

@ -1,7 +1,7 @@
% LTL paper, revised version (v2) addressing ePrint acceptance criteria: % LTL paper, revised version (v2) addressing ePrint acceptance criteria:
% self-containedness, formal model, proofs for the system's own claims, % self-containedness, formal model, proofs for the system's own claims,
% honest title, no marketing. Author TODOs are marked \authortodo{...} % honest title, no marketing. All \authortodo markers have been resolved;
% and MUST be resolved before resubmission. % the macro remains defined in case future edits need it.
\documentclass[11pt]{article} \documentclass[11pt]{article}
\usepackage[a4paper,margin=1.1in]{geometry} \usepackage[a4paper,margin=1.1in]{geometry}
\usepackage{amsmath,amssymb,amsthm} \usepackage{amsmath,amssymb,amsthm}
@ -497,11 +497,13 @@ $(n', r')$:
\item $n' < n_{\mathrm{pin}}$: reject (rollback). \item $n' < n_{\mathrm{pin}}$: reject (rollback).
\end{itemize} \end{itemize}
A freshness policy bounds head age. The full log is also published as a git A freshness policy bounds head age. The full log is also published as a git
repository (one file per leaf, plus every head ever signed), so any cloner repository: one file per leaf, plus the signed head history since
can recompute every prefix root from the public leaves and audit the publication began (heads signed before the mirror existed were not
complete head history without consistency proofs---a low-infrastructure retained). Any cloner can therefore recompute every prefix root from the
witness mechanism~\cite{ct2}; a standalone ${\approx}150$-line public leaves and check every published head against its prefix root and
standard-library verifier ships in the mirror. signature without consistency proofs---a low-infrastructure witness
mechanism~\cite{ct2}; a standalone ${\approx}150$-line standard-library
verifier ships in the mirror.
\section{Security analysis}\label{sec:security} \section{Security analysis}\label{sec:security}
@ -708,8 +710,12 @@ asserts.
\end{proposition} \end{proposition}
\begin{proof} \begin{proof}
By construction of the consumer tooling: the verdict function takes By construction of the consumer tooling: the verdict function takes
$(\obs_a, \allowed)$ and ignores the label fields; an attestation lacking $(\obs_a, \allowed)$ and ignores the label fields in every branch; a
observed cones is mapped to \textsf{unverifiable}, not to a verdict. certificate lacking an observed cone is mapped to \textsf{unverifiable},
not to a verdict. The operator's proven/failed \texttt{status} label is
consulted separately by the acceptance policy, but only
\emph{negatively}---a certificate the operator does not itself mark proven
can never count---so no operator assertion can upgrade any verdict.
\end{proof} \end{proof}
\paragraph{What is \emph{not} proven.} Propositions~\ref{prop:pin} and \paragraph{What is \emph{not} proven.} Propositions~\ref{prop:pin} and
@ -740,6 +746,10 @@ signing\_library\_leaf\_index: 8\\
signing\_library\_certificates\_proven: 16/16\\ signing\_library\_certificates\_proven: 16/16\\
self\_inclusion: verified self\_inclusion: verified
\end{quote} \end{quote}
(These provenance fields ride alongside the signature as operator-provided
context; they are not part of the signed payload, and a consumer relies on
none of them---the acyclic chain below rests only on the signature and the
leaf's inclusion.)
The signature vouches for the tree; the tree vouches for the code that The signature vouches for the tree; the tree vouches for the code that
produced the signature; and the two vouchings are different proof modalities produced the signature; and the two vouchings are different proof modalities
(cryptographic and deductive), so the loop is self-referential without being (cryptographic and deductive), so the loop is self-referential without being
@ -798,8 +808,12 @@ append-only ledger is the point of the system:
changed their commit hashes. Because a leaf pins an exact commit changed their commit hashes. Because a leaf pins an exact commit
(\S\ref{sec:leaves}), the operator re-ran the full corpus and appended (\S\ref{sec:leaves}), the operator re-ran the full corpus and appended
fresh attestations at the new commits rather than editing leaves 4--7. fresh attestations at the new commits rather than editing leaves 4--7.
The proof \emph{files} are unchanged across the rewrite---independently That the proof \emph{files} survived the rewrite unchanged is
checkable by diffing the two commit trees---and the replay confirms it. corroborated from the log itself: leaves 4--7 and 8--11 carry identical
certificate lists and identical observed axiom cones, re-checked by the
kernel at both commit generations. (The pre-rewrite trees themselves are
no longer distributed, so a direct tree diff is not among the public
artifacts.)
\end{itemize} \end{itemize}
\noindent This last event is a live exercise of the append-only \noindent This last event is a live exercise of the append-only
@ -906,9 +920,10 @@ byte-identical across all four; extraction-facing proof scripts diverge
sharply where the forks' code or the extractor's naming differs (e.g., 215 sharply where the forks' code or the extractor's naming differs (e.g., 215
changed lines for the byte-parser proofs on the two forks whose extraction changed lines for the byte-parser proofs on the two forks whose extraction
produces a closure-based loader; 121 lines for the signature-glue proofs on produces a closure-based loader; 121 lines for the signature-glue proofs on
the same-crate fork; 27 lines---all annotation---between the two the same-crate fork; 27 lines between the two structurally closest forks,
structurally closest forks, documenting one fork's \texttt{black\_box} tracking one fork's \texttt{black\_box} optimization barrier---its
optimization barrier). Per-target verification, in other words, is doing axiom-list entries and the operation reordering it induces). Per-target
verification, in other words, is doing
measurable work exactly where the targets actually differ. measurable work exactly where the targets actually differ.
\section{Limitations}\label{sec:limitations} \section{Limitations}\label{sec:limitations}
@ -959,7 +974,7 @@ check scripts.
% Review every proof in Section 6 line by line and re-run every number in % Review every proof in Section 6 line by line and re-run every number in
% Section 8 yourself.} % Section 8 yourself.}
\begin{thebibliography}{19} \begin{thebibliography}{20}
\itemsep2pt \itemsep2pt
\bibitem{ct1} B. Laurie, A. Langley, E. K\"asper. Certificate Transparency. \bibitem{ct1} B. Laurie, A. Langley, E. K\"asper. Certificate Transparency.
@ -969,7 +984,7 @@ RFC 6962, 2013.
Version 2.0. RFC 9162, 2021. Version 2.0. RFC 9162, 2021.
\bibitem{crosby} S. A. Crosby, D. S. Wallach. Efficient Data Structures for \bibitem{crosby} S. A. Crosby, D. S. Wallach. Efficient Data Structures for
Tamper-Evident Logging. USENIX Security, 2009. Tamper-Evident Logging. USENIX Security, pp. 317--334, 2009.
\bibitem{dghs} B. Dowling, F. G\"unther, U. Herath, D. Stebila. Secure \bibitem{dghs} B. Dowling, F. G\"unther, U. Herath, D. Stebila. Secure
Logging Schemes and Certificate Transparency. ESORICS, LNCS 9879, pp. Logging Schemes and Certificate Transparency. ESORICS, LNCS 9879, pp.
@ -980,17 +995,18 @@ Software Signing for Everybody. ACM CCS, pp. 2353--2367, 2022.
\bibitem{intoto} S. Torres-Arias, H. Afzali, T. K. Kuppusamy, R. Curtmola, \bibitem{intoto} S. Torres-Arias, H. Afzali, T. K. Kuppusamy, R. Curtmola,
J. Cappos. in-toto: Providing farm-to-table guarantees for bits and bytes. J. Cappos. in-toto: Providing farm-to-table guarantees for bits and bytes.
USENIX Security, 2019. USENIX Security, pp. 1393--1410, 2019.
\bibitem{coniks} M. S. Melara, A. Blankstein, J. Bonneau, E. W. Felten, \bibitem{coniks} M. S. Melara, A. Blankstein, J. Bonneau, E. W. Felten,
M. J. Freedman. CONIKS: Bringing Key Transparency to End Users. USENIX M. J. Freedman. CONIKS: Bringing Key Transparency to End Users. USENIX
Security, 2015. Security, pp. 383--398, 2015.
\bibitem{pcc} G. C. Necula. Proof-Carrying Code. ACM POPL, pp. 106--119, \bibitem{pcc} G. C. Necula. Proof-Carrying Code. ACM POPL, pp. 106--119,
1997. 1997.
\bibitem{cheval} V. Cheval, J. Moreira, M. Ryan. Automatic verification of \bibitem{cheval} V. Cheval, J. Moreira, M. Ryan. Automatic verification of
transparency protocols. IEEE EuroS\&P, 2023. arXiv:2303.04500. transparency protocols. IEEE EuroS\&P, pp. 107--121, 2023.
arXiv:2303.04500.
\bibitem{easycrypt} G. Barthe, B. Gr\'egoire, S. Heraud, S. Zanella \bibitem{easycrypt} G. Barthe, B. Gr\'egoire, S. Heraud, S. Zanella
B\'eguelin. Computer-Aided Security Proofs for the Working Cryptographer. B\'eguelin. Computer-Aided Security Proofs for the Working Cryptographer.
@ -1004,10 +1020,10 @@ Programming Language. CADE-28, LNCS 12699, pp. 625--635, 2021.
\bibitem{hacl} J.-K. Zinzindohou\'e, K. Bhargavan, J. Protzenko, \bibitem{hacl} J.-K. Zinzindohou\'e, K. Bhargavan, J. Protzenko,
B. Beurdouche. HACL*: A Verified Modern Cryptographic Library. ACM CCS, B. Beurdouche. HACL*: A Verified Modern Cryptographic Library. ACM CCS,
2017. pp. 1789--1806, 2017.
\bibitem{evercrypt} J. Protzenko et al. EverCrypt: A Fast, Verified, \bibitem{evercrypt} J. Protzenko et al. EverCrypt: A Fast, Verified,
Cross-Platform Cryptographic Provider. IEEE S\&P, 2020. Cross-Platform Cryptographic Provider. IEEE S\&P, pp. 983--1002, 2020.
\bibitem{fiatcrypto} A. Erbsen, J. Philipoom, J. Gross, R. Sloan, \bibitem{fiatcrypto} A. Erbsen, J. Philipoom, J. Gross, R. Sloan,
A. Chlipala. Simple High-Level Code for Cryptographic Arithmetic---With A. Chlipala. Simple High-Level Code for Cryptographic Arithmetic---With
@ -1040,10 +1056,10 @@ Each leaf is the canonical JSON serialization (sorted keys, no
insignificant whitespace, UTF-8) of an attestation. Below is leaf~8 of insignificant whitespace, UTF-8) of an attestation. Below is leaf~8 of
the deployed log---the re-attestation of the upstream fork. The the deployed log---the re-attestation of the upstream fork. The
16-certificate array is elided to its first (foundational) and last 16-certificate array is elided to its first (foundational) and last
(apex) entries, and long values (hashes, timestamps, version strings, (apex) entries; long values (hashes, timestamps, version strings,
paths) are shortened with ellipses; field names, structure, and the paths) are shortened, and omitted fields are marked, with ellipses. The
axiom lists are verbatim, and the unelided leaf is one \texttt{jq} field names and values shown, and the axiom lists, are verbatim, and the
invocation away in the public mirror. unelided leaf is one \texttt{jq} invocation away in the public mirror.
\begin{quote}\ttfamily\scriptsize \begin{quote}\ttfamily\scriptsize
\{ "type": "pacta.attestation", "schema\_version": 1,\\ \{ "type": "pacta.attestation", "schema\_version": 1,\\
\hspace*{0.6em}"attestation": \{\\ \hspace*{0.6em}"attestation": \{\\
@ -1051,14 +1067,18 @@ invocation away in the public mirror.
\hspace*{1.2em}"issued\_at": "2026-07-07T...Z",\\ \hspace*{1.2em}"issued\_at": "2026-07-07T...Z",\\
\hspace*{1.2em}"subject": \{ "component": "dalek-ed25519-verified",\\ \hspace*{1.2em}"subject": \{ "component": "dalek-ed25519-verified",\\
\hspace*{2.4em}"repo\_commit": "33fb8bb2311c70ead2e83c0...",\\ \hspace*{2.4em}"repo\_commit": "33fb8bb2311c70ead2e83c0...",\\
\hspace*{2.4em}"repo\_url": ..., "verified\_backend": "serial" \},\\ \hspace*{2.4em}"repo\_url": ..., "verified\_backend": "serial/u64",\\
\hspace*{1.2em}"environment": \{ "lean\_version": "4.x.y",\\ \hspace*{2.4em}... \},\\
\hspace*{1.2em}"environment": \{\\
\hspace*{2.4em}"lean\_version": "Lean (version 4.30.0-rc2, ...)",\\
\hspace*{2.4em}"lake\_version": ..., "env\_script": ...,\\ \hspace*{2.4em}"lake\_version": ..., "env\_script": ...,\\
\hspace*{2.4em}"lean\_project\_dir": ... \},\\ \hspace*{2.4em}"lean\_project\_dir": ... \},\\
\hspace*{1.2em}"machine\_protection": \{ "lean\_guard": ...,\\ \hspace*{1.2em}"machine\_protection": \{ "lean\_guard": ...,\\
\hspace*{2.4em}"note": "memory cap, core pinning, single-flight" \},\\ \hspace*{2.4em}"note": "All Lean compiles route through the\\
\hspace*{2.4em}repo's lean-guard (memory cap, core pinning,\\
\hspace*{2.4em}timeout, single-flight lock) ..." \},\\
\hspace*{1.2em}"replay": \{ "checked\_files": 64, "failed\_files": [],\\ \hspace*{1.2em}"replay": \{ "checked\_files": 64, "failed\_files": [],\\
\hspace*{2.4em}"check\_ok": true, "axiom\_ok": true \},\\ \hspace*{2.4em}"check\_ok": true, "axiom\_ok": true, ... \},\\
\hspace*{1.2em}"certificates": [\\ \hspace*{1.2em}"certificates": [\\
\hspace*{2.4em}\{ "name": "CurveFieldProofs.fieldImplementation",\\ \hspace*{2.4em}\{ "name": "CurveFieldProofs.fieldImplementation",\\
\hspace*{3.0em}"status": "proven", "axiom\_status": "clean",\\ \hspace*{3.0em}"status": "proven", "axiom\_status": "clean",\\
@ -1071,19 +1091,23 @@ invocation away in the public mirror.
\hspace*{3.0em}"status": "proven", "axiom\_status": "clean",\\ \hspace*{3.0em}"status": "proven", "axiom\_status": "clean",\\
\hspace*{3.0em}"observed\_axioms": ["propext","Classical.choice",\\ \hspace*{3.0em}"observed\_axioms": ["propext","Classical.choice",\\
\hspace*{3.6em}"Quot.sound","ed25519.Signature","sha2.Sha512",\\ \hspace*{3.6em}"Quot.sound","ed25519.Signature","sha2.Sha512",\\
\hspace*{3.6em}"verifying.sha512\_new","verifying.sha512\_update",\\
\hspace*{3.6em}"verifying.sha512\_finalize\_bytes",\\ \hspace*{3.6em}"verifying.sha512\_finalize\_bytes",\\
\hspace*{3.6em}"verifying.sha512\_new","verifying.sha512\_update",\\
\hspace*{3.6em}"ed25519.Signature.to\_bytes",\\ \hspace*{3.6em}"ed25519.Signature.to\_bytes",\\
\hspace*{3.6em}"signature.error.Error",\\ \hspace*{3.6em}"signature.error.Error",\\
\hspace*{3.6em}"signature.error.Error.new"] \} ],\\ \hspace*{3.6em}"signature.error.Error.new"] \} ],\\
\hspace*{1.2em}"signature": \{ "scheme": "ed25519", ... \} \} \} \hspace*{1.2em}"signature": \{ "scheme": "openssl-ed25519", ... \} \} \}
\end{quote} \end{quote}
The \texttt{observed\_axioms} field is the exact output of The \texttt{observed\_axioms} field is the exact output of
\texttt{\#print axioms} for that theorem. Operator labels \texttt{\#print axioms} for that theorem. Operator labels
(\texttt{replay.check\_ok}, per-certificate \texttt{status} and (\texttt{replay.check\_ok}, per-certificate \texttt{status} and
\texttt{axiom\_status}) are recorded for the audit trail but are not \texttt{axiom\_status}) are recorded for the audit trail, but the
inputs to any consumer verdict (Proposition~\ref{prop:verdict}); the cleanliness verdict is $\obs = \allowed$ computed against the consumer's
consumer's verdict is $\obs = \allowed$ computed against its own table. own table in every case, with a missing cone mapped to
\textsf{unverifiable} (Proposition~\ref{prop:verdict}). The
\texttt{status} label is consulted only \emph{negatively}: a certificate
the operator itself does not mark proven can never count toward
acceptance, so labels can deny but never grant.
\section{The consumer verifier}\label{app:verifier} \section{The consumer verifier}\label{app:verifier}
@ -1139,8 +1163,8 @@ above, plus):
this fork exposes SHA-512 as three streaming operations): this fork exposes SHA-512 as three streaming operations):
\begin{quote}\ttfamily\scriptsize \begin{quote}\ttfamily\scriptsize
ed25519.Signature \quad sha2.Sha512\\ ed25519.Signature \quad sha2.Sha512\\
verifying.sha512\_new \quad verifying.sha512\_update\\
verifying.sha512\_finalize\_bytes\\ verifying.sha512\_finalize\_bytes\\
verifying.sha512\_new \quad verifying.sha512\_update\\
ed25519.Signature.to\_bytes\\ ed25519.Signature.to\_bytes\\
signature.error.Error \quad signature.error.Error.new signature.error.Error \quad signature.error.Error.new
\end{quote} \end{quote}

View file

@ -230,7 +230,7 @@ the proof-attested Ed25519 code path itself (<code>--require-verified-verifier</
<h2>API</h2> <h2>API</h2>
<pre>GET {base}/v1/sth latest Signed Tree Head <pre>GET {base}/v1/sth latest Signed Tree Head
GET {base}/v1/sth-history every head ever signed (witness material) GET {base}/v1/sth-history the published head history (witness material)
GET {base}/v1/sth-consistency?first=N consistency proof from your pinned size GET {base}/v1/sth-consistency?first=N consistency proof from your pinned size
GET {base}/v1/proof?component=NAME inclusion proof (artifact 3, freshly issued) GET {base}/v1/proof?component=NAME inclusion proof (artifact 3, freshly issued)
GET {base}/v1/attestation?component=NAME the claim (artifact 2) GET {base}/v1/attestation?component=NAME the claim (artifact 2)

View file

@ -129,19 +129,29 @@ def validate_attestation(
if not fresh: if not fresh:
diagnostics.append(error or "Signed tree head fails the freshness policy.") diagnostics.append(error or "Signed tree head fails the freshness policy.")
if sth_store_path: if sth_store_path:
proof_hex = None # The pin-store state machine (including permanent poisoning
if consistency_proof_path: # on equivocation) only ever runs on a VALIDLY SIGNED head;
raw_proof = load_data(consistency_proof_path) # an unauthenticated head must not be able to mutate - let
proof_hex = [str(item) for item in (raw_proof.get("proof") if isinstance(raw_proof, dict) else raw_proof) or []] # alone poison - the consumer's pin.
sth_check = check_sth_against_store( if receipt_result.signatures.get("ed25519") != "verified":
sth, transparency_evidence["sth_store"] = "skipped_unverified_head"
sth_store_path, diagnostics.append(
consistency_proof_hex=proof_hex, "STH store: head signature did not verify; pin store not consulted or updated."
consistency_from=receipt.get("consistency"), )
) else:
transparency_evidence.update(sth_check.evidence()) proof_hex = None
if not sth_check.ok: if consistency_proof_path:
diagnostics.extend("STH store: " + note for note in sth_check.diagnostics) 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 = {
@ -190,13 +200,16 @@ def _normalize_certificate(cert: dict[str, Any], profile: Any) -> dict[str, Any]
status = str(cert.get("status") or "unknown") status = str(cert.get("status") or "unknown")
observed = [str(a) for a in (cert.get("observed_axioms") or [])] observed = [str(a) for a in (cert.get("observed_axioms") or [])]
expected = profile.expected_axioms_for(name) expected = profile.expected_axioms_for(name)
if status == "proven" and observed: # 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" axiom_status = "clean" if sorted(observed) == sorted(expected) else "dirty"
elif status == "proven":
# proven with no observed axioms reported: cannot re-derive; distrust.
axiom_status = "unverifiable"
else: else:
axiom_status = str(cert.get("axiom_status") or "not_checked") # no observed cone: nothing to re-derive from; distrust.
axiom_status = "unverifiable"
provider_verdict = str(cert.get("axiom_status") or "not_stated") provider_verdict = str(cert.get("axiom_status") or "not_stated")
return { return {
"name": name, "name": name,

View file

@ -475,6 +475,7 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int:
sth_store=args.sth_store, sth_store=args.sth_store,
consistency_proof_path=args.consistency_proof, consistency_proof_path=args.consistency_proof,
max_sth_age_seconds=args.max_sth_age_seconds, max_sth_age_seconds=args.max_sth_age_seconds,
head_signature_verified=result.signatures.get("ed25519") == "verified",
) )
if args.require_verified_verifier and result.signatures.get("ed25519_backend") != "verified-dalek-serial": if args.require_verified_verifier and result.signatures.get("ed25519_backend") != "verified-dalek-serial":
accountability_diagnostics.append( accountability_diagnostics.append(
@ -575,6 +576,7 @@ def _log_accountability_checks(
sth_store: str | None, sth_store: str | None,
consistency_proof_path: str | None, consistency_proof_path: str | None,
max_sth_age_seconds: int | None, max_sth_age_seconds: int | None,
head_signature_verified: bool = False,
) -> list[str]: ) -> list[str]:
diagnostics: list[str] = [] diagnostics: list[str] = []
sth = receipt.get("sth") or {} sth = receipt.get("sth") or {}
@ -582,7 +584,13 @@ def _log_accountability_checks(
fresh, error = check_sth_freshness(sth, max_sth_age_seconds) fresh, error = check_sth_freshness(sth, max_sth_age_seconds)
if not fresh: if not fresh:
diagnostics.append(error or "Signed tree head fails the freshness policy.") diagnostics.append(error or "Signed tree head fails the freshness policy.")
if sth_store: if sth_store and not head_signature_verified:
# Only a validly signed head may drive the pin-store state machine;
# an unauthenticated head must not be able to poison the pin.
diagnostics.append(
"STH store: head signature did not verify; pin store not consulted or updated."
)
elif sth_store:
proof_hex = None proof_hex = None
if consistency_proof_path: if consistency_proof_path:
from .yamlio import load_data from .yamlio import load_data

View file

@ -52,3 +52,58 @@ def test_signed_attestation_requires_public_key(tmp_path):
assert not result.accepted assert not result.accepted
assert card["risk"]["level"] == "R0" assert card["risk"]["level"] == "R0"
assert any("attestation-public-key" in item for item in result.diagnostics) assert any("attestation-public-key" in item for item in result.diagnostics)
def test_unverified_head_cannot_touch_pin_store(tmp_path):
"""Regression for the paper's SS5.4 precondition: the pin-store state
machine (including permanent poisoning) runs only on a VALIDLY SIGNED
head. A forged head at the pinned size must not poison the pin."""
import json
from pacta.signing import generate_ed25519_keypair
from pacta_provider.transparency_log import TransparencyLog
key, pub = tmp_path / "log.key", tmp_path / "log.pub"
generate_ed25519_keypair(key, pub)
log = TransparencyLog(tmp_path / "log")
log.init("example-proof-checker.invalid", pub)
att_path = "examples/dalek-ed25519.attestation.yaml"
receipt_path = tmp_path / "receipt.yaml"
log.append_attestation(att_path, key, pub, receipt_out=receipt_path)
raw = load_attestation(att_path)
store = tmp_path / "pins.json"
kwargs = dict(
path=att_path,
trusted_provider="example-proof-checker.invalid",
allow_unsigned=True,
transparency_receipt_path=receipt_path,
transparency_log_public_key_path=pub,
sth_store_path=store,
)
def _pin():
return next(iter(json.loads(store.read_text())["logs"].values()))
# Control: a validly signed head reaches the store and pins.
result = validate_attestation(raw, _repo(), **kwargs)
assert store.exists(), result.diagnostics
pinned = _pin()
assert "poisoned" not in pinned
honest_root = pinned["root_hash"]
# Attack: same size, different root => signature no longer verifies.
# Before the fix this PERMANENTLY POISONED the pin (unauthenticated DoS).
from pacta.yamlio import dump_data, load_data
forged = load_data(receipt_path)
forged["sth"]["root_hash"] = "ab" * 32
forged_path = tmp_path / "forged-receipt.yaml"
dump_data(forged, forged_path)
result = validate_attestation(raw, _repo(), **{**kwargs, "transparency_receipt_path": forged_path})
assert not result.accepted
assert any("pin store not consulted or updated" in d for d in result.diagnostics)
pinned = _pin()
assert "poisoned" not in pinned # the pin survived the forgery
assert pinned["root_hash"] == honest_root
assert result.evidence.get("sth_store") == "skipped_unverified_head"

View file

@ -66,3 +66,27 @@ def test_full_fixture_scores_r4_and_partial_scores_r3(tmp_path):
card = build_claim_card(partial, tmp_path, offline_fixture=True) card = build_claim_card(partial, tmp_path, offline_fixture=True)
assert card["risk"]["level"] == "R3" assert card["risk"]["level"] == "R3"
assert any("R4 requires the full apex tier set" in b for b in card["risk"]["blockers"]) assert any("R4 requires the full apex tier set" in b for b in card["risk"]["blockers"])
def test_verdict_never_copies_operator_labels():
"""Prop 2 (verdict integrity), literally: the cleanliness verdict is a
function of (observed cone, local allowed set) in EVERY branch. The
operator's axiom_status label is never copied - not even for
non-proven certificates - and a missing cone is always unverifiable."""
profile = get_profile("ed25519", _repo())
failed_flattered = {
"name": "CurveFieldProofs.verify_accepts_iff",
"status": "failed",
"axiom_status": "clean", # operator flattery, must not pass through
}
out = _normalize_certificate(failed_flattered, profile)
assert out["axiom_status"] == "unverifiable"
assert out["provider_axiom_verdict"] == "clean" # recorded, not believed
failed_with_cone = {
"name": "CurveFieldProofs.verify_accepts_iff",
"status": "failed",
"axiom_status": "clean",
"observed_axioms": ["propext", "sorryAx"],
}
assert _normalize_certificate(failed_with_cone, profile)["axiom_status"] == "dirty"