Serve the paper at /paper; witness mirror must be independently operated

- web.py: /paper (and /paper/ltl.pdf) serve the committed PDF, loaded
  once at startup from the repo checkout; listed in the 404 endpoint
  index; covered by the web roundtrip test
- docs page: 'The paper' card linking the PDF
- DEPLOY.md: the second witness mirror belongs on a host the operator
  does NOT control (a self-hosted mirror adds no equivocation defense)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-06 19:44:06 +02:00
parent 47dfb61a7f
commit 9acb078844
4 changed files with 32 additions and 7 deletions

View file

@ -168,13 +168,15 @@ server {
Check: `https://ltl.zkdefi.org/docs` renders the customer Check: `https://ltl.zkdefi.org/docs` renders the customer
documentation; `/v1/sth` returns the dogfood-signed head. documentation; `/v1/sth` returns the dogfood-signed head.
## 4. Second mirror (any Forgejo/Gitea/GitLab you operate) ## 4. Second mirror (an independently-operated host — not yours)
Create a periodic pull-mirror of Create a periodic pull-mirror of
`https://github.com/saymrwulf/lean-transparency-log` on a second, `https://github.com/saymrwulf/lean-transparency-log` on a second git
independently-operated git host. The published repo is the witness host **operated by someone else** (e.g. Codeberg). The published repo is
channel; two independent mirrors mean split-view lies must fool two the witness channel; two independent mirrors mean split-view lies must
infrastructures at once — exactly the point. fool two infrastructures at once — exactly the point. A mirror on
infrastructure the log operator also controls adds convenience, not
witness value: the operator could equivocate consistently on both.
## 4b. Key hygiene (non-negotiable) ## 4b. Key hygiene (non-negotiable)

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import json import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@ -20,7 +21,7 @@ from .transparency_log import TransparencyLog
API_VERSION = "v1" API_VERSION = "v1"
def make_handler(log: TransparencyLog, base_path: str, docs_html: str): def make_handler(log: TransparencyLog, base_path: str, docs_html: str, paper_pdf: bytes | None = None):
base = "/" + base_path.strip("/") if base_path.strip("/") else "" base = "/" + base_path.strip("/") if base_path.strip("/") else ""
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@ -43,6 +44,16 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str):
if route in ("/", "/docs"): if route in ("/", "/docs"):
self._send_html(docs_html) self._send_html(docs_html)
elif route in ("/paper", "/paper/ltl.pdf"):
if paper_pdf is None:
self._send(404, {"error": "paper not available on this deployment"})
return
self.send_response(200)
self.send_header("Content-Type", "application/pdf")
self.send_header("Content-Disposition", 'inline; filename="ltl.pdf"')
self.send_header("Content-Length", str(len(paper_pdf)))
self.end_headers()
self.wfile.write(paper_pdf)
elif route == "/healthz": elif route == "/healthz":
self._send(200, {"ok": True, "tree_size": len(log.entries())}) self._send(200, {"ok": True, "tree_size": len(log.entries())})
elif route == f"/{API_VERSION}/metadata": elif route == f"/{API_VERSION}/metadata":
@ -112,6 +123,7 @@ def make_handler(log: TransparencyLog, base_path: str, docs_html: str):
"error": "unknown endpoint", "error": "unknown endpoint",
"endpoints": [ "endpoints": [
f"{base}/docs", f"{base}/docs",
f"{base}/paper",
f"{base}/healthz", f"{base}/healthz",
f"{base}/{API_VERSION}/metadata", f"{base}/{API_VERSION}/metadata",
f"{base}/{API_VERSION}/sth", f"{base}/{API_VERSION}/sth",
@ -161,5 +173,7 @@ def serve(
from .webdocs import render_docs from .webdocs import render_docs
docs_html = render_docs(log, base_path) docs_html = render_docs(log, base_path)
handler = make_handler(log, base_path, docs_html) paper_path = Path(__file__).resolve().parents[3] / "paper" / "ltl.pdf"
paper_pdf = paper_path.read_bytes() if paper_path.is_file() else None
handler = make_handler(log, base_path, docs_html, paper_pdf)
return ThreadingHTTPServer((host, port), handler) return ThreadingHTTPServer((host, port), handler)

View file

@ -218,6 +218,12 @@ the content hash) and build it yourself — compiler and build are declared trus
until the reproducible-builds program (R5) lands. Every attestation carries its full until the reproducible-builds program (R5) lands. Every attestation carries its full
residual-risk list. Honesty about the boundary is the product.</div> residual-risk list. Honesty about the boundary is the product.</div>
<h2>The paper</h2>
<div class="card"><a href="{base}/paper"><strong>LTL: the Lean Transparency Log</strong></a>
(PDF, 4 pages) the design in full: the trust model (observations, never verdicts),
the self-certifying signature, the deployment with its retained failure leaves, and an
exact account of what a verified receipt does and does not establish.</div>
<p class="muted">Log heads are signed offline; this service is read-only and holds no <p class="muted">Log heads are signed offline; this service is read-only and holds no
key material. Provider tooling, agent tooling, and the full course (12 Jupyter key material. Provider tooling, agent tooling, and the full course (12 Jupyter
lectures) live in the <a href="https://github.com/saymrwulf/proof-aware-crypto-tooling-agent">pacta repository</a>.</p> lectures) live in the <a href="https://github.com/saymrwulf/proof-aware-crypto-tooling-agent">pacta repository</a>.</p>

View file

@ -56,6 +56,9 @@ def test_web_endpoints_and_online_proof_roundtrip(tmp_path):
assert consistency["from_tree_size"] == 2 and consistency["proof"] assert consistency["from_tree_size"] == 2 and consistency["proof"]
history = get("/v1/sth-history")["sth_history"] history = get("/v1/sth-history")["sth_history"]
assert len(history) == 3 # one head per append assert len(history) == 3 # one head per append
with urllib.request.urlopen(base + "/paper", timeout=10) as r:
assert r.headers["Content-Type"] == "application/pdf"
assert r.read(5) == b"%PDF-"
finally: finally:
server.shutdown() server.shutdown()