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 · vouches · quorum config · transparency log
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
+ +Newest first. Hashes are truncated for display — full values are at /federation/log.
| id | When | Type | Peer / target | Signal id | Hash |
|---|---|---|---|---|---|
| {{ 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] }}… | +
(chain empty — no signals appended yet)
+ {% endif %} +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.
+ + + +A peer's feed gets ingested only when its fingerprint is eligible (directly trusted or vouched into trust).
+ + {% if peer_rows %} +| Domain | Fingerprint | Status | Vouched | Eligible |
|---|---|---|---|---|
| {{ 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 %} | +
(no peers registered yet)
+ {% endif %} +Distinct eligible-peer counts per signal hash. Quorum is met when count ≥ {{ cfg.signal_quorum_k }}.
+ + {% if hash_summary %} +| Latest | Type | Signal id | Hash | Distinct peers | Eligible | Quorum |
|---|---|---|---|---|---|---|
| {{ (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 %} + | +
(no signals in buffer yet)
+ {% endif %} +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
+ +We've signed these — peers that fetch our feed will see them and may extend trust accordingly.
+ + {% if our_vouches %} +| Target fingerprint | Issued | Expires | |
|---|---|---|---|
| {{ v.target_fingerprint }} | +{{ v.issued_at.isoformat()[:16] | replace('T', ' ') }} | +{{ v.expires_at.isoformat()[:16] | replace('T', ' ') if v.expires_at else '—' }} | ++ + | +
(no vouches issued yet)
+ {% endif %} +Vouch for a peer's fingerprint. Trusted peers see this and may treat the target as listening-eligible.
+ +Threshold: {{ cfg.trust_min_vouchers }} distinct trusted vouchers required to make a non-trusted peer listening-eligible.
+ + {% if peer_rows %} +| Peer | Status | Vouches | Quorum met | Eligible |
|---|---|---|---|---|
| {{ 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 %} + | +
(no peers registered yet)
+ {% endif %} +