Add evote cockpit: watch the cryptography execute as live typeset math

The differentiator. A single self-contained browser page (no libraries, offline)
renders each real cryptographic operation as typeset mathematics the instant it
runs, with the actual runtime values:

- E_1 = (γ, φ) = (g^r, pk^r·m),  r ← Z_q      (ElGamal ballot encryption)
- e = H((p,q,g), y, c, h_aux) mod q            (Fiat-Shamir challenge)
- C' = { ReEnc_pk(C_π(i); ρ_i) }              (Bayer-Groth verifiable shuffle)
- σ ← Ed25519.Sign_sk(SHA256(envelope))        (transport signature)
- s = a·B = b·A ∈ X25519,  k = SHA256(…)      (X25519 key agreement)

Math is rendered via a focused LaTeX→native-MathML converter written for exactly
the notation the instrumentation emits — so it works in any modern browser with
zero dependencies and nothing to ship. Unknown tokens fall back to literal text,
never crashing the view.

`evote cockpit` starts an HTTP server; on page connect it runs one full multi-
party ceremony, streaming every crypto event over SSE with configurable pacing
(--delay) so a human can follow along. A stakeholder sidebar highlights the
acting party; a phase timeline tracks setup→cards→voting→tally→verify; each op
shows its live values as expandable, copyable chips.

Verified in a real browser: all five operation kinds render correctly (96 sign,
36 challenge, 6 keyex, 2 encrypt, 5 shuffle in a 2-voter run), no console errors,
ceremony completes and verifies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-07-07 14:19:13 +02:00
parent bc43c3d3aa
commit 1e0b286bd5
5 changed files with 464 additions and 1 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "cockpit",
"runtimeExecutable": "./evote",
"runtimeArgs": ["cockpit", "--port", "8092", "--voters", "2", "--options", "3", "--delay", "40"],
"port": 8092
}
]
}

3
.gitignore vendored
View file

@ -8,5 +8,8 @@ testdata/*.json
# Rust build artifacts (the static lib is rebuilt via `make rust` / `cargo build`)
rust/**/target/
# Local Claude Code settings (keep launch.json, ignore per-user settings)
.claude/settings.local.json
# macOS
.DS_Store

187
cmd/evote/cockpit.go Normal file
View file

@ -0,0 +1,187 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io/fs"
"math/big"
"net/http"
"sync/atomic"
"time"
"github.com/spf13/cobra"
"github.com/user/evote/pkg/party"
"github.com/user/evote/pkg/protocol"
"github.com/user/evote/pkg/trace"
)
var (
cockpitPort int
cockpitVoters int
cockpitOptions int
cockpitDelayMs int
cockpitRunning atomic.Bool
)
var cockpitCmd = &cobra.Command{
Use: "cockpit",
Short: "Watch the cryptography execute live in the browser (typeset math)",
Long: "Runs the multi-party election and streams every cryptographic operation " +
"— sampling, ElGamal encryption, Fiat-Shamir challenges, the Bayer-Groth " +
"shuffle, Ed25519 signatures, X25519 key agreement — to a browser page that " +
"renders each as typeset mathematics with the real runtime values, the instant " +
"it runs. Open the printed URL.",
RunE: func(cmd *cobra.Command, args []string) error {
if cockpitVoters < 1 || cockpitVoters > 200 {
return fmt.Errorf("--voters must be in [1, 200]")
}
if cockpitOptions < 1 || cockpitOptions > 200 {
return fmt.Errorf("--options must be in [1, 200]")
}
return runCockpit()
},
}
func init() {
cockpitCmd.Flags().IntVar(&cockpitPort, "port", 8090, "HTTP port")
cockpitCmd.Flags().IntVar(&cockpitVoters, "voters", 3, "Number of voters")
cockpitCmd.Flags().IntVar(&cockpitOptions, "options", 3, "Number of voting options")
cockpitCmd.Flags().IntVar(&cockpitDelayMs, "delay", 350, "Milliseconds between events (pacing so it's watchable)")
rootCmd.AddCommand(cockpitCmd)
}
func runCockpit() error {
mux := http.NewServeMux()
// The cockpit page + assets are embedded under web/ (see serve.go's webContent).
webFS, err := fs.Sub(webContent, "web")
if err != nil {
return err
}
fileServer := http.FileServer(http.FS(webFS))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
data, err := fs.ReadFile(webFS, "cockpit.html")
if err != nil {
http.Error(w, "cockpit.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
return
}
fileServer.ServeHTTP(w, r)
})
// SSE stream: on connect, run one ceremony and stream its crypto events.
mux.HandleFunc("/events", cockpitEventsHandler)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cockpitPort),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
url := fmt.Sprintf("http://localhost:%d/", cockpitPort)
fmt.Println("========================================")
fmt.Println(" Swiss Post E-Voting — Live Crypto Cockpit")
fmt.Println("========================================")
fmt.Printf(" Open %s in your browser.\n", url)
fmt.Printf(" Voters: %d, Options: %d, pacing: %dms/event\n", cockpitVoters, cockpitOptions, cockpitDelayMs)
fmt.Println(" The election runs when the page connects; every crypto operation")
fmt.Println(" is rendered as typeset math with its real live values.")
return srv.ListenAndServe()
}
// cockpitEventsHandler streams one ceremony's crypto events as Server-Sent
// Events. It subscribes a buffered sink, runs the ceremony in a goroutine, and
// drains the buffer to the client with pacing so a human can follow along.
func cockpitEventsHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
// The trace stream and its party/phase context are global, so only one
// ceremony may run at a time (this is a single-viewer desktop tool).
if !cockpitRunning.CompareAndSwap(false, true) {
http.Error(w, "a ceremony is already streaming; reload after it finishes", http.StatusConflict)
return
}
defer cockpitRunning.Store(false)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sink := trace.NewChanSink(8192)
unsub := trace.Subscribe(sink)
defer unsub()
done := make(chan error, 1)
go func() { done <- runCockpitCeremony() }()
delay := time.Duration(cockpitDelayMs) * time.Millisecond
send := func(eventType string, payload any) {
b, _ := json.Marshal(payload)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b)
flusher.Flush()
}
for {
select {
case e := <-sink.C:
send("op", e)
if delay > 0 {
time.Sleep(delay)
}
case err := <-done:
// Drain any remaining buffered events before closing.
for {
select {
case e := <-sink.C:
send("op", e)
default:
msg := "complete"
if err != nil {
msg = "error: " + err.Error()
}
send("done", map[string]string{"status": msg})
return
}
}
case <-r.Context().Done():
return
}
}
}
// runCockpitCeremony runs a full multi-party election (the same one netdemo
// runs), emitting trace events as it goes.
func runCockpitCeremony() error {
cfg := protocol.DefaultConfig(cockpitVoters, cockpitOptions)
c, err := party.NewCeremony(cfg, func(string, ...any) {})
if err != nil {
return err
}
if err := c.RunSetup(); err != nil {
return err
}
if err := c.RunCards(); err != nil {
return err
}
selections := make([][]int, cockpitVoters)
for v := 0; v < cockpitVoters; v++ {
n, err := rand.Int(rand.Reader, big.NewInt(int64(cockpitOptions)))
if err != nil {
return err
}
selections[v] = []int{int(n.Int64())}
}
if err := c.RunVoting(selections); err != nil {
return err
}
if err := c.RunTally(); err != nil {
return err
}
return c.RunVerify()
}

262
cmd/evote/web/cockpit.html Normal file
View file

@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>E-Voting — Live Crypto Cockpit</title>
<style>
:root{
--bg:#ffffff; --fg:#1f2328; --dim:#57606a; --line:#d0d7de; --soft:#f6f8fa;
--accent:#0969da; --ok:#1a7f37; --warn:#9a6700; --sign:#8250df; --shuffle:#bf3989;
--sample:#0a7ea3; --encrypt:#1a7f37; --challenge:#9a6700; --keyex:#6639ba;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
header{padding:14px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:16px;flex-wrap:wrap;}
header h1{font-size:17px;margin:0;font-weight:700;}
header .sub{color:var(--dim);font-size:13px;}
.timeline{display:flex;gap:6px;margin-left:auto;flex-wrap:wrap;}
.phase{font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:4px 10px;border:1px solid var(--line);border-radius:999px;color:var(--dim);}
.phase.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.phase.done{background:var(--soft);color:var(--ok);border-color:var(--ok);}
main{display:grid;grid-template-columns:230px 1fr;height:calc(100% - 56px);}
@media(max-width:760px){main{grid-template-columns:1fr;}#parties{display:none}}
#parties{border-right:1px solid var(--line);padding:14px;overflow:auto;background:var(--soft);}
#parties h2{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin:0 0 10px;}
.party{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;font-size:13px;color:var(--dim);transition:background .2s,color .2s;}
.party .dot{width:8px;height:8px;border-radius:50%;background:var(--line);flex:none;}
.party.live{background:#fff;color:var(--fg);font-weight:600;box-shadow:0 0 0 1px var(--line);}
.party.live .dot{background:var(--accent);box-shadow:0 0 0 3px rgba(9,105,218,.15);}
#feed{overflow:auto;padding:18px 22px;}
.op{border:1px solid var(--line);border-left-width:4px;border-radius:10px;padding:12px 16px;margin:0 0 12px;animation:pop .25s ease;}
@keyframes pop{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.op .row{display:flex;align-items:baseline;gap:10px;margin-bottom:8px;flex-wrap:wrap;}
.badge{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:999px;color:#fff;}
.op .who{font-weight:600;font-size:13px;}
.op .cap{color:var(--dim);font-size:13px;}
.op .seq{margin-left:auto;color:var(--line);font-size:12px;font-variant-numeric:tabular-nums;}
math{font-size:19px;}
.vals{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px;}
.val{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--soft);border:1px solid var(--line);border-radius:6px;padding:2px 8px;cursor:pointer;color:var(--fg);}
.val .k{color:var(--accent);}
.val.open{white-space:normal;word-break:break-all;max-width:100%;}
.k-sign{border-left-color:var(--sign)} .k-sign .badge{background:var(--sign)}
.k-shuffle{border-left-color:var(--shuffle)} .k-shuffle .badge{background:var(--shuffle)}
.k-sample{border-left-color:var(--sample)} .k-sample .badge{background:var(--sample)}
.k-encrypt{border-left-color:var(--encrypt)} .k-encrypt .badge{background:var(--encrypt)}
.k-decrypt{border-left-color:var(--sample)} .k-decrypt .badge{background:var(--sample)}
.k-challenge{border-left-color:var(--challenge)} .k-challenge .badge{background:var(--challenge)}
.k-keyex{border-left-color:var(--keyex)} .k-keyex .badge{background:var(--keyex)}
.k-proof{border-left-color:var(--accent)} .k-proof .badge{background:var(--accent)}
.k-verify{border-left-color:var(--ok)} .k-verify .badge{background:var(--ok)}
.k-note .badge{background:var(--dim)}
#status{padding:10px 22px;border-top:1px solid var(--line);color:var(--dim);font-size:13px;background:var(--soft);}
.done-banner{color:var(--ok);font-weight:700;}
</style>
</head>
<body>
<header>
<h1>🔐 Live Crypto Cockpit</h1>
<span class="sub">the cryptography, rendered as it runs</span>
<div class="timeline" id="timeline"></div>
</header>
<main>
<aside id="parties"><h2>Stakeholders</h2><div id="partyList"></div></aside>
<section id="feed"></section>
</main>
<div id="status">Connecting…</div>
<script>
"use strict";
// compact value elision (mirrors trace.Short in Go)
function short(s){ if(s.length<=15) return s; return s.slice(0,8)+"…"+s.slice(-6); }
// ============================================================
// Focused LaTeX -> MathML converter.
// Handles exactly the subset emitted by pkg/trace templates. Native MathML
// renders offline in every modern browser — no library, no fonts to ship.
// Unknown tokens fall back to literal text so nothing ever crashes the view.
// ============================================================
const GREEK={sigma:"σ",gamma:"γ",rho:"ρ",pi:"π",phi:"φ",tau:"τ",lambda:"λ",mu:"μ",delta:"δ"};
const OPS={cdot:"⋅",in:"∈",gets:"←",Vert:"∥",times:"×",oplus:"⊕",bmod:"mod",
qquad:" ",quad:" "};
const BB={Z:"ℤ",X:"𝕏",N:"ℕ",G:"𝔾",F:"𝔽",Q:"ℚ"};
const CAL={H:"ℋ",R:"ℛ",C:"𝒞",E:"ℰ"};
const MML="http://www.w3.org/1998/Math/MathML";
function el(tag,...kids){ const m=document.createElementNS(MML,tag);
for(const k of kids){ if(k==null) continue; m.appendChild(typeof k==="string"?document.createTextNode(k):k);} return m; }
function mi(t){return el("mi",t);} function mo(t){return el("mo",t);} function mn(t){return el("mn",t);}
function mtext(t){return el("mtext",t);}
function mspace(){const s=el("mspace"); s.setAttribute("width","0.5em"); return s;}
function tokenize(src){
const out=[]; let i=0;
while(i<src.length){
const c=src[i];
if(c==="\\"){
let j=i+1,name="";
if(/[a-zA-Z]/.test(src[j])){ while(j<src.length&&/[a-zA-Z]/.test(src[j])){name+=src[j++];} }
else { name=src[j]; j++; }
out.push({t:"cmd",v:name}); i=j; continue;
}
if(c==="{"){ let depth=1,j=i+1; while(j<src.length&&depth>0){ if(src[j]==="{")depth++; else if(src[j]==="}")depth--; if(depth>0)j++; }
out.push({t:"grp",v:src.slice(i+1,j)}); i=j+1; continue; }
if(c==="^"||c==="_"){ out.push({t:c}); i++; continue; }
if(c===" "){ i++; continue; }
out.push({t:"chr",v:c}); i++;
}
return out;
}
function render(src,values){
const toks=tokenize(src); const nodes=[];
for(let i=0;i<toks.length;i++){
const tk=toks[i];
if(tk.t==="^"||tk.t==="_"){
const base=nodes.pop()||mi("");
const nxt=toks[++i];
const sub=nxt.t==="grp"?groupOf(render(nxt.v,values)):renderOne(nxt,values);
nodes.push(el(tk.t==="^"?"msup":"msub",base,sub||mi("")));
continue;
}
const consume=()=>toks[++i];
const n=renderOne(tk,values,consume);
if(Array.isArray(n)) nodes.push(...n); else if(n) nodes.push(n);
}
return nodes;
}
function groupOf(arr){ if(Array.isArray(arr)) return arr.length===1?arr[0]:el("mrow",...arr); return arr; }
function renderOne(tk,values,consume){
if(tk.t==="grp") return groupOf(render(tk.v,values));
if(tk.t==="chr"){
const c=tk.v;
if(/[0-9]/.test(c)) return mn(c);
if(/[a-zA-Z]/.test(c)) return mi(c);
if(c==="'") return mo("′");
return mo(c);
}
if(tk.t==="cmd"){
const v=tk.v;
if(v==="VAL"){ const g=consume&&consume(); const name=g?g.v:""; const full=(values&&values[name])||name;
return mtext(" "+short(full)+" "); }
if(v in GREEK) return mi(GREEK[v]);
if(v==="mathbb"||v==="mathcal"||v==="mathbf"||v==="boldsymbol"||v==="mathrm"||v==="text"||v==="operatorname"){
const g=consume&&consume(); const inner=g?g.v:"";
if(v==="mathbb") return mi(BB[inner]||inner);
if(v==="mathcal") return mi(CAL[inner]||inner);
if(v==="mathrm"||v==="operatorname"){ const t=el("mi",inner); t.setAttribute("mathvariant","normal"); return t; }
if(v==="text") return mtext(inner);
const b=groupOf(render(inner,values)); if(b&&b.setAttribute) b.setAttribute("mathvariant","bold"); return b;
}
if(v==="frac"){ const a=consume&&consume(),b=consume&&consume();
return el("mfrac",groupOf(render(a?a.v:"",values)),groupOf(render(b?b.v:"",values))); }
if(v==="sqrt"){ const a=consume&&consume(); return el("msqrt",groupOf(render(a?a.v:"",values))); }
if(v==="xleftarrow"){ const a=consume&&consume();
return el("mover",mo("←"),el("mtext",a?a.v.replace(/\\\$/,"$"):"")); }
if(v==="big"||v==="Big"||v==="bigg"||v==="left"||v==="right"||v==="displaystyle") return null;
// spacing control symbols: \, \: \; \ (backslash-space) -> spaces; \! -> nothing
if(v==="!") return null;
if(v==="," ){ const s=el("mspace"); s.setAttribute("width","0.17em"); return s; }
if(v===":" ){ const s=el("mspace"); s.setAttribute("width","0.22em"); return s; }
if(v===";" ){ const s=el("mspace"); s.setAttribute("width","0.28em"); return s; }
if(v===" " ){ const s=el("mspace"); s.setAttribute("width","0.33em"); return s; }
if(v in OPS){ const s=OPS[v]; if(s===" ") return mspace();
if(/^[a-z]+$/i.test(s)){ const o=el("mo",s); o.setAttribute("lspace","0.25em"); o.setAttribute("rspace","0.25em"); return o; }
return mo(s); }
if(v==="{"||v==="}"||v==="|") return mo(v==="|"?"∥":v);
if(v==="\\") return null;
return mi(v); // unknown: literal, never crash
}
return null;
}
function mathFor(latex,values){
const m=el("math"); m.setAttribute("display","block");
try{ m.appendChild(el("mrow",...render(latex,values))); }
catch(e){ m.appendChild(mtext(latex)); }
return m;
}
// ============================================================
// Live UI
// ============================================================
const PARTY_ORDER=["setup-component","control-component-0","control-component-1",
"control-component-2","control-component-3","electoral-board","voting-server","verifier"];
const PHASES=["setup","cards","voting","tally","verify"];
const feed=document.getElementById("feed");
const statusEl=document.getElementById("status");
const partyList=document.getElementById("partyList");
const timeline=document.getElementById("timeline");
const partyEls={};
let curPhase="";
function label(p){ return p.replace("control-component-","CC").replace("electoral-board","Electoral Board")
.replace("voting-server","Voting Server").replace("setup-component","Setup").replace("verifier","Verifier"); }
function initUI(){
for(const p of PARTY_ORDER){
const d=document.createElement("div"); d.className="party";
d.innerHTML='<span class="dot"></span><span>'+label(p)+'</span>';
partyList.appendChild(d); partyEls[p]=d;
}
for(const ph of PHASES){ const s=document.createElement("span"); s.className="phase"; s.textContent=ph; s.dataset.ph=ph; timeline.appendChild(s); }
}
function setPhase(ph){
if(ph===curPhase) return; curPhase=ph;
for(const s of timeline.children){
const i=PHASES.indexOf(s.dataset.ph), c=PHASES.indexOf(ph);
s.className="phase"+(i<c?" done":i===c?" active":"");
}
}
function markLive(party){
for(const p in partyEls) partyEls[p].classList.remove("live");
if(party && partyEls[party]) partyEls[party].classList.add("live");
}
function escapeHtml(s){ return (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function addOp(e){
if(e.phase) setPhase(e.phase);
markLive(e.party);
const card=document.createElement("div");
card.className="op k-"+e.kind;
const row=document.createElement("div"); row.className="row";
row.innerHTML='<span class="badge">'+escapeHtml(e.kind)+'</span>'+
'<span class="who">'+escapeHtml(e.party||"")+'</span>'+
'<span class="cap">'+escapeHtml(e.caption||"")+'</span>'+
'<span class="seq">#'+e.seq+'</span>';
card.appendChild(row);
if(e.latex) card.appendChild(mathFor(e.latex,e.values||{}));
if(e.values && Object.keys(e.values).length){
const vd=document.createElement("div"); vd.className="vals";
for(const k in e.values){
const full=e.values[k];
const chip=document.createElement("span"); chip.className="val"; chip.title="click to expand / copy";
const paint=()=>{ chip.innerHTML='<span class="k">'+escapeHtml(k)+'</span> = '+escapeHtml(chip.classList.contains("open")?full:short(full)); };
paint();
chip.onclick=()=>{ chip.classList.toggle("open"); paint();
if(chip.classList.contains("open")&&navigator.clipboard) navigator.clipboard.writeText(full).catch(()=>{}); };
vd.appendChild(chip);
}
card.appendChild(vd);
}
feed.appendChild(card);
feed.scrollTop=feed.scrollHeight;
}
initUI();
const es=new EventSource("/events");
let count=0;
es.addEventListener("op",ev=>{ const e=JSON.parse(ev.data); count++; addOp(e);
statusEl.textContent="Streaming live crypto operations… "+count+" so far"; });
es.addEventListener("done",ev=>{ markLive(null);
for(const s of timeline.children) s.className="phase done";
statusEl.innerHTML='<span class="done-banner">✓ Election complete and verified.</span> '+count+' cryptographic operations rendered live.';
es.close(); });
es.onerror=()=>{ if(count===0) statusEl.textContent="Stream ended, or a run is already in progress — reload to restart."; };
</script>
</body>
</html>

View file

@ -65,7 +65,7 @@ func (e *Envelope) Seal(sender *Identity) error {
Party: e.From,
Kind: trace.KindSign,
Caption: fmt.Sprintf("%s signs %q → %s", e.From, e.Type, e.To),
LaTeX: `\sigma \gets \mathrm{Ed25519.Sign}_{sk_{\text{\VAL{party}}}}\!\big(\mathrm{SHA256}(\text{envelope})\big),\quad |\sigma| = 64\text{ B}`,
LaTeX: `\sigma \gets \mathrm{Ed25519.Sign}_{sk}\!\big(\mathrm{SHA256}(\text{envelope})\big),\quad |\sigma| = 64\text{ B}`,
ASCII: "σ ← Ed25519.Sign(sk, H(envelope))",
Values: map[string]string{
"party": e.From,