mirror of
https://github.com/saymrwulf/swisspost-evoting-go-poc.git
synced 2026-11-03 04:41:16 +00:00
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:
parent
bc43c3d3aa
commit
1e0b286bd5
5 changed files with 464 additions and 1 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
3
.gitignore
vendored
|
|
@ -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
187
cmd/evote/cockpit.go
Normal 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
262
cmd/evote/web/cockpit.html
Normal 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue