From 0e56fa70af34513d6826b1a6caf73d88b01458a8 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sat, 6 Jun 2026 21:11:03 +0200 Subject: [PATCH] stage-vouch-d federation: cockpit pages + CLI + public endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public JSON endpoints (no auth): - GET /federation/vouches — our_vouches() so peers can pull our trust - GET /federation/log — last 100 transparency-log entries - GET /federation/log/verify — re-walks the chain, returns {verified, head_hash} or 409 with {error, head_hash} Admin pages (TOTP-gated): - /admin/federation/vouches — issued list, issue form, revoke buttons, per-peer quorum-met table - /admin/federation/log — chain verification status + last 200 entries - /admin/federation/quorum — config form + per-peer eligibility + per-signal-hash distinct-eligible-peer counts CLI: fed-vouch / fed-unvouch / fed-vouches / fed-quorum-set / fed-log / fed-log-verify, plus existing fed-* commands untouched. The base /admin/federation page now links to the three new sub-pages. Co-Authored-By: Claude Opus 4.7 --- src/psyc/_federation_cli.py | 79 +++++++- src/psyc/cockpit/federation_routes.py | 182 +++++++++++++++++- .../cockpit/templates/admin_federation.html | 2 +- .../templates/admin_federation_log.html | 63 ++++++ .../templates/admin_federation_quorum.html | 92 +++++++++ .../templates/admin_federation_vouches.html | 110 +++++++++++ 6 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 src/psyc/cockpit/templates/admin_federation_log.html create mode 100644 src/psyc/cockpit/templates/admin_federation_quorum.html create mode 100644 src/psyc/cockpit/templates/admin_federation_vouches.html diff --git a/src/psyc/_federation_cli.py b/src/psyc/_federation_cli.py index e01bbe6..8a660ab 100644 --- a/src/psyc/_federation_cli.py +++ b/src/psyc/_federation_cli.py @@ -13,7 +13,7 @@ import httpx import typer from psyc import db, log -from psyc.lines import federation +from psyc.lines import federation, translog from psyc.result import Err @@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None: db.init_db() federation.remove_peer(domain) typer.echo(f"removed {domain}") + + # ---------- vouching + quorum -------------------------------------- + + @typer_app.command("fed-vouch") + def fed_vouch( + target_fp: str = typer.Argument(..., help="target peer fingerprint (32 hex)"), + ttl_days: int = typer.Option(90, "--ttl-days", help="vouch lifetime in days"), + ) -> None: + """Issue a signed vouch for `target_fp`. Persists locally + rides our feed.""" + db.init_db() + v = federation.issue_vouch(target_fp.strip(), ttl_days=ttl_days) + typer.echo(f"vouched: {v.target_fingerprint} (expires {v.expires_at})") + + @typer_app.command("fed-unvouch") + def fed_unvouch(target_fp: str = typer.Argument(...)) -> None: + """Revoke OUR vouch for `target_fp`.""" + db.init_db() + federation.revoke_vouch(target_fp.strip()) + typer.echo(f"revoked vouch for {target_fp}") + + @typer_app.command("fed-vouches") + def fed_vouches() -> None: + """List vouches WE have issued.""" + db.init_db() + rows = federation.our_vouches() + if not rows: + typer.echo("(no vouches issued)") + return + for v in rows: + exp = v.expires_at.isoformat() if v.expires_at else "—" + typer.echo(f" {v.target_fingerprint} issued={v.issued_at.isoformat()[:16]} expires={exp[:16]}") + + @typer_app.command("fed-quorum-set") + def fed_quorum_set( + trust: Optional[int] = typer.Option(None, "--trust", help="trust_min_vouchers"), + k: Optional[int] = typer.Option(None, "--k", help="signal_quorum_k"), + ) -> None: + """Update quorum thresholds. Either flag is optional — only changed values overwrite.""" + db.init_db() + cfg = federation.quorum_config() + if trust is not None: + cfg.trust_min_vouchers = max(1, int(trust)) + if k is not None: + cfg.signal_quorum_k = max(1, int(k)) + federation.set_quorum_config(cfg) + typer.echo(f"quorum: trust_min_vouchers={cfg.trust_min_vouchers} signal_quorum_k={cfg.signal_quorum_k}") + + # ---------- transparency log -------------------------------------- + + @typer_app.command("fed-log") + def fed_log( + limit: int = typer.Option(20, "--limit", help="number of entries to show"), + ) -> None: + """Print recent transparency-log entries (newest first).""" + db.init_db() + rows = translog.recent(limit=limit) + if not rows: + typer.echo("(transparency log empty)") + return + for e in rows: + typer.echo( + f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}…" + ) + + @typer_app.command("fed-log-verify") + def fed_log_verify() -> None: + """Re-walk the chain locally and report verification status.""" + db.init_db() + result = translog.verify_chain() + head = translog.head() + head_hash = head.entry_hash if head else "(empty)" + if isinstance(result, Err): + typer.echo(f" ✗ broken: {result.reason}", err=True) + typer.echo(f" head_hash: {head_hash}") + raise typer.Exit(1) + typer.echo(f" ✓ verified {result.value} entries") + typer.echo(f" head_hash: {head_hash}") diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index d402f38..6e4fabf 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -15,7 +15,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red from fastapi.templating import Jinja2Templates from psyc import db, log -from psyc.lines import federation +from psyc.lines import federation, translog +from psyc.result import Err _log = log.get(__name__) @@ -122,4 +123,183 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: def federation_feed() -> JSONResponse: return JSONResponse(_cached_feed()) + # ---------- public vouches + transparency log -------------------- + + @app.get("/federation/vouches") + def federation_vouches() -> JSONResponse: + """Vouches WE have issued. Peers fetch this to learn who we trust.""" + return JSONResponse({ + "fingerprint": federation.node_fingerprint(), + "vouches": [v.model_dump(mode="json") for v in federation.our_vouches()], + }) + + @app.get("/federation/log") + def federation_log() -> JSONResponse: + """Last 100 transparency-log entries, newest first.""" + entries = translog.recent(limit=100) + return JSONResponse({ + "count": len(entries), + "entries": [e.model_dump(mode="json") for e in entries], + }) + + @app.get("/federation/log/verify") + def federation_log_verify() -> JSONResponse: + """Re-walk the chain locally and report status. Auditors poll this.""" + result = translog.verify_chain() + head = translog.head() + head_hash = head.entry_hash if head else None + if isinstance(result, Err): + return JSONResponse({"error": result.reason, "head_hash": head_hash}, status_code=409) + return JSONResponse({"verified": result.value, "head_hash": head_hash}) + + # ---------- admin: vouches page --------------------------------- + + @app.get("/admin/federation/vouches", response_class=HTMLResponse) + def admin_federation_vouches(request: Request) -> HTMLResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + peers = federation.list_peers() + cfg = federation.quorum_config() + ours = federation.our_vouches() + # Per-peer view: vouches naming each peer and whether quorum is met. + peer_rows = [] + for p in peers: + vouches = federation.vouches_for(p.fingerprint) + peer_rows.append({ + "peer": p, + "vouches": vouches, + "vouched": federation.is_vouched(p.fingerprint), + "eligible": federation.peer_is_listening_eligible(p.fingerprint), + }) + return TEMPLATES.TemplateResponse( + request, + "admin_federation_vouches.html", + { + "fingerprint": federation.node_fingerprint(), + "our_vouches": ours, + "peer_rows": peer_rows, + "cfg": cfg, + }, + ) + + @app.post("/admin/federation/vouches/issue") + def admin_federation_vouch_issue( + request: Request, + target_fingerprint: str = Form(...), + ttl_days: int = Form(90), + ) -> RedirectResponse: + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + try: + federation.issue_vouch(target_fingerprint.strip(), ttl_days=ttl_days) + except Exception as exc: + _log.warning("federation.vouch.issue.error", error=str(exc)) + return RedirectResponse("/admin/federation/vouches", status_code=303) + + @app.post("/admin/federation/vouches/revoke") + def admin_federation_vouch_revoke( + request: Request, + target_fingerprint: str = Form(...), + ) -> RedirectResponse: + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + federation.revoke_vouch(target_fingerprint.strip()) + return RedirectResponse("/admin/federation/vouches", status_code=303) + + # ---------- admin: transparency log page ------------------------ + + @app.get("/admin/federation/log", response_class=HTMLResponse) + def admin_federation_log(request: Request) -> HTMLResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + result = translog.verify_chain() + head = translog.head() + entries = translog.recent(limit=200) + verify_status: Dict[str, Any] + if isinstance(result, Err): + verify_status = {"ok": False, "reason": result.reason} + else: + verify_status = {"ok": True, "verified": result.value} + return TEMPLATES.TemplateResponse( + request, + "admin_federation_log.html", + { + "verify_status": verify_status, + "head_hash": head.entry_hash if head else "", + "head_id": head.id if head else 0, + "entries": entries, + }, + ) + + # ---------- admin: quorum config + per-peer/per-hash view ------- + + @app.get("/admin/federation/quorum", response_class=HTMLResponse) + def admin_federation_quorum(request: Request) -> HTMLResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + cfg = federation.quorum_config() + peers = federation.list_peers() + peer_rows = [ + { + "peer": p, + "vouched": federation.is_vouched(p.fingerprint), + "eligible": federation.peer_is_listening_eligible(p.fingerprint), + } + for p in peers + ] + # Group buffered signals by signal_hash and count distinct eligible peers. + signal_rows: Dict[str, Dict[str, Any]] = {} + for s in db.recent_signals(limit=500): + h = s.get("signal_hash") or "" + entry = signal_rows.setdefault(h, { + "signal_hash": h, + "signal_type": s.get("signal_type") or "", + "signal_id": s.get("signal_id") or "", + "peers": set(), + "latest": s.get("received_at") or "", + }) + entry["peers"].add(s.get("peer_fingerprint") or "") + hash_summary = [] + for h, row in signal_rows.items(): + distinct_eligible = sum( + 1 for fp in row["peers"] if federation.peer_is_listening_eligible(fp) + ) + hash_summary.append({ + "signal_hash": h, + "signal_type": row["signal_type"], + "signal_id": row["signal_id"], + "distinct_peers": len(row["peers"]), + "distinct_eligible": distinct_eligible, + "quorum_met": distinct_eligible >= cfg.signal_quorum_k, + "latest": row["latest"], + }) + hash_summary.sort(key=lambda r: r["latest"], reverse=True) + return TEMPLATES.TemplateResponse( + request, + "admin_federation_quorum.html", + { + "cfg": cfg, + "peer_rows": peer_rows, + "hash_summary": hash_summary, + }, + ) + + @app.post("/admin/federation/quorum/save") + def admin_federation_quorum_save( + request: Request, + trust_min_vouchers: int = Form(...), + signal_quorum_k: int = Form(...), + ) -> RedirectResponse: + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + try: + cfg = federation.QuorumConfig( + trust_min_vouchers=max(1, int(trust_min_vouchers)), + signal_quorum_k=max(1, int(signal_quorum_k)), + ) + federation.set_quorum_config(cfg) + except Exception as exc: + _log.warning("federation.quorum.save.error", error=str(exc)) + return RedirectResponse("/admin/federation/quorum", status_code=303) + _log.info("federation.routes.registered") diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html index b9493bd..8d7bfaa 100644 --- a/src/psyc/cockpit/templates/admin_federation.html +++ b/src/psyc/cockpit/templates/admin_federation.html @@ -8,7 +8,7 @@ {{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}

This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.

-

← back to admin

+

← back to admin  ·  vouches  ·  quorum config  ·  transparency log

node fingerprint
diff --git a/src/psyc/cockpit/templates/admin_federation_log.html b/src/psyc/cockpit/templates/admin_federation_log.html new file mode 100644 index 0000000..341b24e --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation_log.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Transparency Log — psyc admin{% endblock %} +{% block content %} + +
+
+

Transparency Log

+ id @ {{ head_id }} +
+

Every signal we accept from a peer is appended to a signed merkle chain. Each entry references the previous entry's hash, so tampering with any historical row invalidates every entry after. Auditors can re-walk and detect a bad peer historically — even one we trusted at the time.

+

← back to federation  ·  vouches  ·  quorum config  ·  public verify endpoint

+ +
+
chain verification
+ {% if verify_status.ok %} +
verified   {{ verify_status.verified }} entries walked, no breaks
+ {% else %} +
BROKEN   {{ verify_status.reason }}
+ {% endif %} + {% if head_hash %} +
head hash
+
{{ head_hash }}
+ {% endif %} +
+
+ +
+
+

Recent Entries

+ {{ entries|length }} of last 200 +
+

Newest first. Hashes are truncated for display — full values are at /federation/log.

+ + {% if entries %} + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
idWhenTypePeer / targetSignal idHash
{{ e.id }}{{ (e.timestamp or '')[:19] | replace('T', ' ') }}{{ e.entry_type }} + {% if e.entry_type == 'signal' %} + {{ (e.entry_data.peer_fingerprint or '')[:8] }}… + {% elif e.entry_type == 'vouch' %} + {{ (e.entry_data.voucher_fingerprint or '')[:8] }}…→{{ (e.entry_data.target_fingerprint or '')[:8] }}… + {% else %} + — + {% endif %} + {{ ((e.entry_data.signal_id or e.entry_data.target_fingerprint or '') | string)[:32] }}{{ e.entry_hash[:16] }}…
+ {% else %} +

(chain empty — no signals appended yet)

+ {% endif %} +
+ +{% endblock %} diff --git a/src/psyc/cockpit/templates/admin_federation_quorum.html b/src/psyc/cockpit/templates/admin_federation_quorum.html new file mode 100644 index 0000000..25e91a4 --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation_quorum.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Quorum Config — psyc admin{% endblock %} +{% block content %} + +
+
+

Quorum Configuration

+ trust={{ cfg.trust_min_vouchers }} k={{ cfg.signal_quorum_k }} +
+

trust_min_vouchers — distinct trusted vouchers required to make a new peer listening-eligible. signal_quorum_k — distinct listening-eligible peers required to consider a signal_hash quorum-met. Both gates live in pulse_settings; raising them tightens trust, lowering them relaxes it.

+

← back to federation  ·  vouches  ·  transparency log

+ +
+ + + + + +
+
+ +
+
+

Per-Peer Listening Eligibility

+ {{ peer_rows|length }} +
+

A peer's feed gets ingested only when its fingerprint is eligible (directly trusted or vouched into trust).

+ + {% if peer_rows %} + + + + {% for row in peer_rows %} + + + + + + + + {% endfor %} + +
DomainFingerprintStatusVouchedEligible
{{ row.peer.domain }}{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }} + {% if row.peer.status == 'trusted' %} + trusted + {% elif row.peer.status == 'blocked' %} + blocked + {% else %} + {{ row.peer.status }} + {% endif %} + {% if row.vouched %}yes{% else %}no{% endif %}{% if row.eligible %}listening{% else %}muted{% endif %}
+ {% else %} +

(no peers registered yet)

+ {% endif %} +
+ +
+
+

Signal Hashes in Buffer

+ {{ hash_summary|length }} hashes +
+

Distinct eligible-peer counts per signal hash. Quorum is met when count ≥ {{ cfg.signal_quorum_k }}.

+ + {% if hash_summary %} + + + + {% for r in hash_summary %} + + + + + + + + + + {% endfor %} + +
LatestTypeSignal idHashDistinct peersEligibleQuorum
{{ (r.latest or '')[:19] | replace('T', ' ') }}{{ r.signal_type }}{{ r.signal_id[:48] }}{{ r.signal_hash[:16] }}…{{ r.distinct_peers }}{{ r.distinct_eligible }} + {% if r.quorum_met %} + met + {% else %} + below + {% endif %} +
+ {% else %} +

(no signals in buffer yet)

+ {% endif %} +
+ +{% endblock %} diff --git a/src/psyc/cockpit/templates/admin_federation_vouches.html b/src/psyc/cockpit/templates/admin_federation_vouches.html new file mode 100644 index 0000000..98f337e --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation_vouches.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Federation Vouches — psyc admin{% endblock %} +{% block content %} + +
+
+

Web of Trust

+ {{ our_vouches|length }} issued +
+

A vouch is an Ed25519-signed assertion that we trust another node's fingerprint. Peers gossip our vouches with their feeds, so trust accumulates: once {{ cfg.trust_min_vouchers }} of our trusted peers vouches for a new fingerprint, it becomes listening-eligible — its signed feeds get ingested.

+

← back to federation  ·  quorum config  ·  transparency log

+ +
+
our fingerprint
+
{{ fingerprint }}
+
+
+ +
+
+

Vouches We've Issued

+ {{ our_vouches|length }} +
+

We've signed these — peers that fetch our feed will see them and may extend trust accordingly.

+ + {% if our_vouches %} + + + + {% for v in our_vouches %} + + + + + + + {% endfor %} + +
Target fingerprintIssuedExpires
{{ v.target_fingerprint }}{{ v.issued_at.isoformat()[:16] | replace('T', ' ') }}{{ v.expires_at.isoformat()[:16] | replace('T', ' ') if v.expires_at else '—' }} +
+ + +
+
+ {% else %} +

(no vouches issued yet)

+ {% endif %} +
+ +
+
+

Issue a Vouch

+
+

Vouch for a peer's fingerprint. Trusted peers see this and may treat the target as listening-eligible.

+
+ + + +
+
+ +
+
+

Per-Peer Quorum Status

+ {{ peer_rows|length }} peers +
+

Threshold: {{ cfg.trust_min_vouchers }} distinct trusted vouchers required to make a non-trusted peer listening-eligible.

+ + {% if peer_rows %} + + + + {% for row in peer_rows %} + + + + + + + + {% endfor %} + +
PeerStatusVouchesQuorum metEligible
{{ row.peer.domain }}
{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}
+ {% if row.peer.status == 'trusted' %} + trusted + {% elif row.peer.status == 'blocked' %} + blocked + {% else %} + {{ row.peer.status }} + {% endif %} + {{ row.vouches|length }} + {% if row.vouched %} + yes + {% else %} + no + {% endif %} + + {% if row.eligible %} + listening + {% else %} + muted + {% endif %} +
+ {% else %} +

(no peers registered yet)

+ {% endif %} +
+ +{% endblock %}