Compare commits

..

No commits in common. "a1e4ec6ce734fa845c80856ae541746e033b7200" and "cad6e112e3ba9a75c4723f800c1f42abde7dd5f4" have entirely different histories.

29 changed files with 2 additions and 1384 deletions

View file

@ -1,11 +0,0 @@
{
"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,8 +8,5 @@ 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

View file

@ -68,10 +68,6 @@ make test # runs cargo test + go test ./...
./evote netdemo --voters 10 --options 3
./evote netdemo --voters 3 --options 2 --verbose # log every signed message
# Watch the cryptography execute as live math — two surfaces, one event stream:
./evote cockpit --voters 3 --options 3 # browser: typeset MathML at http://localhost:8090
./evote cockpit --tmux --voters 3 # terminal: one tmux pane per stakeholder
# Serve presentations on local network (for iPad viewing)
./evote serve --port 8080
@ -79,30 +75,6 @@ make test # runs cargo test + go test ./...
./evote present
```
The `cockpit` command is a teaching instrument: it runs the full multi-party
ceremony and streams **every cryptographic operation** as **the mathematics that
is executing, with the real runtime values**, the instant it runs. It goes down
to the level of the Swiss Post `crypto-primitives` class structure — you can watch
the Bayer-Groth shuffle proof being *constructed*, not just referenced:
- ElGamal encryption, Fiat-Shamir challenges, Ed25519 signatures, X25519 key agreement
- **Pedersen matrix commitments** (`CommitmentService`)
- **all five Bayer-Groth sub-arguments** (`ShuffleArgument`, `ProductArgument`,
`HadamardArgument`, `ZeroArgument`, `SingleValueProductArgument`,
`MultiExponentiationArgument`)
- **partial decryption + decryption proofs** (`DecryptionProofService`)
One `pkg/trace` event stream feeds two surfaces:
- **Browser** (default): a self-contained page renders each operation as typeset
MathML (no libraries, offline). A stakeholder sidebar highlights the acting
party; a phase timeline tracks setup→cards→voting→tally→verify.
- **Terminal** (`--tmux`): one tmux pane per stakeholder, each rendering its
party's operations as colored ASCII/Unicode math — the "autonomous parties"
view.
`--delay` paces the events so a human can follow along.
The `demo` command runs the whole protocol in one process. `netdemo` runs the
**multi-party** architecture: every party is a separate endpoint holding only its
own private state, and every message between them is Ed25519-signed (and, for

View file

@ -1,220 +0,0 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"os/exec"
"runtime"
"sync"
"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
cockpitTmux bool
cockpitNoOpen bool
cockpitMu sync.Mutex // serializes ceremonies (global trace context)
)
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]")
}
if cockpitTmux {
return runCockpitTmux()
}
return runCockpit()
},
}
func init() {
cockpitCmd.Flags().IntVar(&cockpitPort, "port", 8090, "HTTP port (browser mode)")
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)")
cockpitCmd.Flags().BoolVar(&cockpitTmux, "tmux", false, "Terminal mode: one tmux pane per stakeholder instead of the browser")
cockpitCmd.Flags().BoolVar(&cockpitNoOpen, "no-open", false, "Do not open the browser automatically")
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{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// Bind the listener first so we only open the browser once we're accepting
// connections — and so a busy port fails with a clear message, not a race.
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cockpitPort))
if err != nil {
return fmt.Errorf("cannot listen on port %d (is another cockpit already running?): %w", cockpitPort, err)
}
url := fmt.Sprintf("http://localhost:%d/", cockpitPort)
fmt.Println("========================================")
fmt.Println(" Swiss Post E-Voting — Live Crypto Cockpit")
fmt.Println("========================================")
fmt.Printf(" Watching at %s\n", url)
fmt.Printf(" Voters: %d, Options: %d, pacing: %dms/event\n", cockpitVoters, cockpitOptions, cockpitDelayMs)
fmt.Println(" The election plays automatically when the page opens.")
fmt.Println(" Press Ctrl+C here to stop.")
if !cockpitNoOpen {
go openBrowser(url)
}
return srv.Serve(ln)
}
// openBrowser opens url in the default browser, cross-platform. Best-effort.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond) // let Serve settle
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler"}
default:
cmd = "xdg-open"
}
_ = exec.Command(cmd, append(args, url)...).Start()
}
// 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 runs at a time. A second viewer waits gracefully for the first to
// finish rather than getting an error (single-viewer desktop tool).
cockpitMu.Lock()
defer cockpitMu.Unlock()
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()
}

View file

@ -1,113 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
"github.com/user/evote/pkg/party"
"github.com/user/evote/pkg/trace"
)
// runCockpitTmux runs the ceremony and shows one tmux pane per stakeholder, each
// rendering that party's live crypto activity as ASCII/Unicode math. The panes
// follow a shared NDJSON event file that the ceremony writes to, paced.
func runCockpitTmux() error {
if _, err := exec.LookPath("tmux"); err != nil {
return fmt.Errorf("tmux not found on PATH: %w", err)
}
if os.Getenv("TMUX") != "" {
return fmt.Errorf("already inside a tmux session — run `evote cockpit --tmux` from a plain terminal (tmux cannot nest an attach)")
}
exe, err := os.Executable()
if err != nil {
return err
}
// Shared event file the panes tail and the ceremony appends to.
f, err := os.CreateTemp("", "evote-cockpit-*.ndjson")
if err != nil {
return err
}
eventPath := f.Name()
defer os.Remove(eventPath)
// One pane per infrastructure party, plus one aggregate pane for all voters.
panes := []string{
party.NameSetup,
party.CCName(0), party.CCName(1), party.CCName(2), party.CCName(3),
party.NameEB, party.NameServer, party.NameVerifier,
"voters",
}
session := "evote-cockpit"
_ = exec.Command("tmux", "kill-session", "-t", session).Run() // best-effort clean slate
paneCmd := func(role string) string {
return fmt.Sprintf("%s panelview --role=%s --file=%s", exe, role, eventPath)
}
// First pane creates the (detached) session.
if out, err := exec.Command("tmux", "new-session", "-d", "-s", session, paneCmd(panes[0])).CombinedOutput(); err != nil {
return fmt.Errorf("tmux new-session: %v: %s", err, out)
}
for _, role := range panes[1:] {
if out, err := exec.Command("tmux", "split-window", "-t", session, paneCmd(role)).CombinedOutput(); err != nil {
exec.Command("tmux", "kill-session", "-t", session).Run()
return fmt.Errorf("tmux split-window: %v: %s", err, out)
}
// Re-tile after each split so panes stay balanced.
exec.Command("tmux", "select-layout", "-t", session, "tiled").Run()
}
exec.Command("tmux", "select-layout", "-t", session, "tiled").Run()
exec.Command("tmux", "set-option", "-t", session, "mouse", "on").Run()
// Run the ceremony in the background, writing paced NDJSON to the file.
go writeCeremonyEvents(f)
fmt.Printf("Launching tmux cockpit '%s' — %d panes. Detach with Ctrl-b d; it closes when you exit.\n", session, len(panes))
time.Sleep(400 * time.Millisecond) // let panes open the file first
// Attach in the foreground; blocks until the user detaches or exits.
attach := exec.Command("tmux", "attach", "-t", session)
attach.Stdin, attach.Stdout, attach.Stderr = os.Stdin, os.Stdout, os.Stderr
err = attach.Run()
exec.Command("tmux", "kill-session", "-t", session).Run()
if err != nil {
return fmt.Errorf("tmux attach failed (run this from an interactive terminal): %w", err)
}
return nil
}
// writeCeremonyEvents subscribes a paced file sink, runs the full ceremony, and
// writes a final done marker.
func writeCeremonyEvents(f *os.File) {
delay := time.Duration(cockpitDelayMs) * time.Millisecond
sink := &fileSink{f: f, delay: delay, enc: json.NewEncoder(f)}
unsub := trace.Subscribe(sink)
defer unsub()
_ = runCockpitCeremony()
f.WriteString(`{"done":true}` + "\n")
f.Sync()
}
// fileSink appends each event as one JSON line, pacing between events so the
// panes render at a watchable rate.
type fileSink struct {
f *os.File
delay time.Duration
enc *json.Encoder
}
func (s *fileSink) Handle(e trace.Event) {
_ = s.enc.Encode(e) // Encode writes the trailing newline
s.f.Sync()
if s.delay > 0 {
time.Sleep(s.delay)
}
}

View file

@ -1,156 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/user/evote/pkg/trace"
)
var (
panelRole string
panelFile string
panelWidth int
)
// panelViewCmd is run inside each tmux pane. It follows a shared NDJSON event
// file and renders the operations belonging to one stakeholder as ASCII/Unicode
// math. It is an internal helper for `evote cockpit --tmux`, but works standalone
// against any NDJSON trace file.
var panelViewCmd = &cobra.Command{
Use: "panelview",
Short: "Render one stakeholder's live crypto activity (used by cockpit --tmux)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if panelRole == "" || panelFile == "" {
return fmt.Errorf("--role and --file are required")
}
return runPanelView()
},
}
func init() {
panelViewCmd.Flags().StringVar(&panelRole, "role", "", "Stakeholder role to display (or 'voters', 'all')")
panelViewCmd.Flags().StringVar(&panelFile, "file", "", "Shared NDJSON event file to follow")
panelViewCmd.Flags().IntVar(&panelWidth, "width", 0, "Wrap width (0 = terminal default)")
rootCmd.AddCommand(panelViewCmd)
}
// ANSI colors keyed by operation kind.
var kindColor = map[trace.Kind]string{
trace.KindSign: "\x1b[35m", // magenta
trace.KindShuffle: "\x1b[95m", // bright magenta
trace.KindEncrypt: "\x1b[32m", // green
trace.KindDecrypt: "\x1b[36m", // cyan
trace.KindChallenge: "\x1b[33m", // yellow
trace.KindKeyEx: "\x1b[34m", // blue
trace.KindProof: "\x1b[36m",
trace.KindVerify: "\x1b[92m",
trace.KindSample: "\x1b[36m",
}
const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
)
func panelMatches(role, party string) bool {
if role == "all" {
return true
}
if role == "voters" {
return strings.HasPrefix(party, "voter")
}
return party == role
}
func runPanelView() error {
// Header.
title := panelRole
switch {
case panelRole == "voters":
title = "Voters"
case strings.HasPrefix(panelRole, "control-component-"):
title = "CC" + strings.TrimPrefix(panelRole, "control-component-")
default:
title = strings.Title(strings.ReplaceAll(panelRole, "-", " "))
}
fmt.Printf("%s%s▐ %s ▌%s\n", ansiBold, "\x1b[7m", title, ansiReset)
fmt.Printf("%swaiting for activity…%s\n", ansiDim, ansiReset)
f, err := openWithRetry(panelFile, 5*time.Second)
if err != nil {
return err
}
defer f.Close()
reader := bufio.NewReader(f)
shown := 0
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if line == `{"done":true}` {
fmt.Printf("\n%s%s✓ ceremony complete%s\n", ansiBold, "\x1b[32m", ansiReset)
return nil
}
var e trace.Event
if json.Unmarshal([]byte(line), &e) == nil && panelMatches(panelRole, e.Party) {
if shown == 0 {
fmt.Print("\x1b[1A\x1b[2K") // erase the "waiting…" line
}
printPanelEvent(e)
shown++
}
}
if err != nil { // EOF: wait for more lines
time.Sleep(80 * time.Millisecond)
}
}
}
func printPanelEvent(e trace.Event) {
col := kindColor[e.Kind]
if col == "" {
col = "\x1b[37m"
}
fmt.Printf("%s%s%-9s%s %s#%d%s %s\n", ansiBold, col, strings.ToUpper(string(e.Kind)), ansiReset,
ansiDim, e.Seq, ansiReset, e.Caption)
if e.ASCII != "" {
fmt.Printf(" %s%s%s\n", col, e.ASCII, ansiReset)
} else if e.LaTeX != "" {
fmt.Printf(" %s%s%s\n", ansiDim, e.LaTeX, ansiReset)
}
if len(e.Values) > 0 {
parts := make([]string, 0, len(e.Values))
for k, v := range e.Values {
parts = append(parts, fmt.Sprintf("%s=%s", k, trace.Short(v)))
}
fmt.Printf(" %s%s%s\n", ansiDim, strings.Join(parts, " "), ansiReset)
}
}
// openWithRetry waits up to timeout for the file to appear (the writer may start
// a moment after the panes).
func openWithRetry(path string, timeout time.Duration) (*os.File, error) {
deadline := time.Now().Add(timeout)
for {
f, err := os.Open(path)
if err == nil {
return f, nil
}
if time.Now().After(deadline) {
return nil, err
}
time.Sleep(50 * time.Millisecond)
}
}

View file

@ -1,279 +0,0 @@
<!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);display:flex;align-items:center;gap:14px;}
.done-banner{color:var(--ok);font-weight:700;}
#replay{margin-left:auto;display:none;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px inherit;padding:6px 16px;border-radius:999px;cursor:pointer;}
#replay:hover{background:#0a5bc4;}
#replay.show{display:inline-block;}
</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"><span id="statusText">Starting the election…</span><button id="replay" onclick="location.reload()">▶ Run the election again</button></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",
circ:"∘",ast:"",sim:"",log:"log",prod:"∏",sum:"∑",
cdots:"⋯",ldots:"…",dots:"…",vdots:"⋮",ddots:"⋱",
qquad:" ",quad:" "};
// big operators get msubsup limits from a following _{}^{}
const BIGOP={prod:"∏",sum:"∑"};
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 readScript=()=>{ const nxt=toks[++i]; return nxt&&nxt.t==="grp"?groupOf(render(nxt.v,values)):(nxt?renderOne(nxt,values):null)||mi(""); };
let sup=null,sub=null;
if(tk.t==="^") sup=readScript(); else sub=readScript();
// pair a complementary script if the next token supplies it (x_{..}^{..})
const nx=toks[i+1];
if(nx&&(nx.t==="^"||nx.t==="_")&&((nx.t==="^")!==(sup!=null))){
i++; if(nx.t==="^") sup=readScript(); else sub=readScript();
}
if(sub&&sup) nodes.push(el("msubsup",base,sub,sup));
else if(sub) nodes.push(el("msub",base,sub));
else nodes.push(el("msup",base,sup));
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==="Bigg"||v==="left"||v==="right"||v==="displaystyle"||v==="textstyle") 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("statusText");
const replayBtn=document.getElementById("replay");
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.';
replayBtn.classList.add("show");
es.close(); });
es.onerror=()=>{ if(count===0) statusEl.textContent="Waiting for the election to start…"; };
</script>
</body>
</html>

View file

@ -1016,7 +1016,6 @@ r = <span class="red">833704513946265251405968611993787771762...</span>
<span class="cmt">"The output ciphertexts are the correct multi-exponentiations</span>
<span class="cmt"> of the inputs with the permutation matrix as exponents."</span></div>
<div class="analogy-box"><strong>Simplified intuition:</strong> Imagine you have 6 sealed boxes numbered 1-6. You rearrange them and put them in new boxes. The ProductArgument proves "I used each number exactly once" (it's a valid permutation). The MultiExponentiationArgument proves "the new boxes contain the same items as the old boxes" (the ciphertexts were correctly re-encrypted, not swapped for fakes).</div>
<div class="insight-box"><strong>Watch it happen.</strong> Run <code>evote cockpit</code> (browser) or <code>evote cockpit --tmux</code> (terminal): as the mix-net executes, each of these sub-arguments is rendered as live typeset mathematics with the real runtime values, in the order it is constructed &mdash; the Pedersen commitment to the permutation matrix, then the Product / Hadamard / Zero / SingleValueProduct / MultiExponentiation arguments, then the partial decryptions. The tree above is not a diagram of the code; it <em>is</em> the code, narrating itself.</div>
`
},

View file

@ -1,12 +1,10 @@
package mixnet
import (
"fmt"
"math/big"
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
)
var oneBI = big.NewInt(1)
@ -103,19 +101,5 @@ func (ck CommitmentKey) CommitMatrix(A *emath.ZqMatrix, r *emath.ZqVector) *emat
for j := 0; j < A.NumCols(); j++ {
commitments[j] = ck.Commit(A.GetColumn(j), r.Get(j))
}
result := emath.GqVectorOf(commitments...)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Kind: trace.KindCommit,
Caption: fmt.Sprintf("Pedersen commitment to a %d×%d matrix (one per column)", A.NumRows(), A.NumCols()),
LaTeX: `\mathbf{c}_A = \mathrm{Comm}_{ck}(A;\, \mathbf{r}), \quad c_{A,j} = h^{r_j} \textstyle\prod_{i=1}^{n} g_i^{A_{ij}}`,
ASCII: "c_A,j = h^r_j · Π_i g_i^{A_ij} (Pedersen, per column)",
Values: map[string]string{
"rows": fmt.Sprintf("%d", A.NumRows()),
"cols": fmt.Sprintf("%d", A.NumCols()),
"c_A0": result.Get(0).Value().String(),
},
}
})
return result
return emath.GqVectorOf(commitments...)
}

View file

@ -30,12 +30,6 @@ func GenHadamardArgument(
n := A.NumRows()
m := A.NumCols()
emitArgument("hadamard",
"Hadamard argument: prove the entrywise product of A's columns equals b",
`\text{HadamardArgument}:\ \mathbf{b} = \mathbf{a}_1 \circ \mathbf{a}_2 \circ \cdots \circ \mathbf{a}_m \quad(\circ = \text{entrywise product})`,
"HadamardArgument: b = a_1 ∘ a_2 ∘ … ∘ a_m (∘ = entrywise product)",
dims(m, n))
// 1. Compute intermediate products b_j = ∏_{i=0}^j A[:,i] (entrywise)
bIntermediate := make([]*emath.ZqVector, m)
bIntermediate[0] = A.GetColumn(0)

View file

@ -37,12 +37,6 @@ func GenMultiExponentiationArgument(
m := A.NumCols()
zero, _ := emath.NewZqElement(big.NewInt(0), zqGroup)
emitArgument("multiexp",
"Multi-exponentiation argument: prove the target ciphertext is the committed re-encryption product",
`\text{MultiExpArgument}:\ C = \Big(\prod_{i=1}^{m}\prod_{j=1}^{n} C_{ij}^{A_{ij}}\Big)\cdot \mathrm{ReEnc}_{pk}(1;\rho)`,
"MultiExpArgument: C = Π_ij C_ij^{A_ij} · ReEnc_pk(1; ρ)",
dims(m, n))
// 1. Random values
a0 := emath.RandomZqVector(n, zqGroup)
r0 := emath.RandomZqElement(zqGroup)

View file

@ -27,12 +27,6 @@ func GenProductArgument(
n := A.NumRows()
m := A.NumCols()
emitArgument("product",
"Product argument: prove the product of all matrix entries equals b",
`\text{ProductArgument}:\ \prod_{i=1}^{n}\prod_{j=1}^{m} A_{ij} = b \quad(\text{via Hadamard} \circ \text{SVP})`,
"ProductArgument: Π_ij A_ij = b (Hadamard ∘ single-value-product)",
dims(m, n))
if m == 1 {
// Single column: just use SVP directly
svp := GenSingleValueProductArgument(cA.Get(0), b, A.GetColumn(0), r.Get(0), pk, ck, group)

View file

@ -30,12 +30,6 @@ func GenShuffleArgument(
N := C.Size()
m, n := GetMatrixDimensions(N)
emitArgument("shuffle",
"Shuffle argument: prove C' is a permutation + re-encryption of C, without revealing π",
`\text{ShuffleArgument}:\ \prod_i C_i^{\,x^{i}} \;\sim\; \prod_i C'^{\,x^{\pi(i)}}_i`,
"ShuffleArgument: Π_i C_i^{x^i} ~ Π_i C'_i^{x^π(i)} (bound via c_A, c_B; challenges x,y,z)",
dims(m, n))
// 1. Convert permutation to m×n matrix A
aCols := make([]*emath.ZqVector, m)
rA := emath.RandomZqVector(m, zqGroup)

View file

@ -1,7 +1,6 @@
package mixnet
import (
"fmt"
"math/big"
"github.com/user/evote/pkg/elgamal"
@ -34,12 +33,6 @@ func GenSingleValueProductArgument(
n := a.Size()
zero, _ := emath.NewZqElement(big.NewInt(0), zqGroup)
emitArgument("svp",
"Single-value product argument: prove the product of a committed vector equals b",
`\text{SingleValueProductArgument}:\ \prod_{i=1}^{n} a_i = b, \quad c_a = \mathrm{Comm}(a; r)`,
"SingleValueProductArgument: Π_i a_i = b for committed vector a",
map[string]string{"n": fmt.Sprintf("%d", n), "b": b.Value().String()})
// 1. Compute partial products b_k = Π_{i=0}^k a_i
bPartial := make([]emath.ZqElement, n)
bPartial[0] = a.Get(0)

View file

@ -1,26 +0,0 @@
package mixnet
import (
"fmt"
"github.com/user/evote/pkg/trace"
)
// emitArgument publishes a KindArgument trace event for one Bayer-Groth
// sub-argument, mirroring the *ArgumentService classes of the Swiss Post
// crypto-primitives library. Cheap when tracing is off.
func emitArgument(name, caption, latex, ascii string, values map[string]string) {
trace.EmitFunc(func() trace.Event {
return trace.Event{
Kind: trace.KindArgument,
Caption: caption,
LaTeX: latex,
ASCII: ascii,
Values: values,
}
})
}
func dims(m, n int) map[string]string {
return map[string]string{"m": fmt.Sprintf("%d", m), "n": fmt.Sprintf("%d", n)}
}

View file

@ -1,11 +1,8 @@
package mixnet
import (
"fmt"
"github.com/user/evote/pkg/elgamal"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
)
// VerifiableShuffle holds the result of a verifiable shuffle.
@ -33,20 +30,6 @@ func GenVerifiableShuffle(
// Generate shuffle argument
arg := GenShuffleArgument(C, shuffle.Shuffled, shuffle.Perm, shuffle.Rho, pk, ck, group)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Kind: trace.KindShuffle,
Caption: fmt.Sprintf("Bayer-Groth verifiable shuffle of %d ciphertexts", N),
LaTeX: `\mathbf{C}' = \big\{\, \mathrm{ReEnc}_{pk}\!\big(C_{\pi(i)};\, \rho_i\big) \,\big\}_{i=1}^{\VAL{N}}, \qquad \pi \xleftarrow{\$} S_{\VAL{N}}`,
ASCII: "C' = { ReEnc_pk(C_π(i); ρ_i) } for a secret permutation π",
Values: map[string]string{
"N": fmt.Sprintf("%d", N),
"m": fmt.Sprintf("%d", N/n),
"n": fmt.Sprintf("%d", n),
},
}
})
return VerifiableShuffle{
ShuffledCiphertexts: shuffle.Shuffled,
Argument: arg,

View file

@ -37,12 +37,6 @@ func GenZeroArgument(
n := A.NumRows()
m := A.NumCols()
emitArgument("zero",
"Zero argument: prove a bilinear map over A and B vanishes",
`\text{ZeroArgument}:\ \sum_{i=1}^{m} \mathbf{a}_i \ast_y \mathbf{b}_i = 0, \quad \mathbf{a}\ast_y\mathbf{b}=\textstyle\sum_j a_j b_j y^{j}`,
"ZeroArgument: Σ_i (a_i _y b_i) = 0 with bilinear star-map _y",
dims(m, n))
// 1. Prepend random a_0 to A, append random b_m to B
a0 := emath.RandomZqVector(n, zqGroup)
r0 := emath.RandomZqElement(zqGroup)

View file

@ -7,7 +7,6 @@ import (
"github.com/user/evote/pkg/elgamal"
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp"
)
@ -52,7 +51,6 @@ func (c *Ceremony) RunSetup() error {
group := cfg.Group
zq := emath.ZqGroupFromGqGroup(group)
trace.Phase("setup")
c.logf("\n--- SETUP PHASE (multi-party) ---")
// 1. Encoding primes (public, computed by the setup component).
@ -170,7 +168,6 @@ func (p *ControlComponent) handleGenCCKeys(env *transport.Envelope) (*transport.
group := cfg.Group
zq := emath.ZqGroupFromGqGroup(group)
trace.SetContext(p.id.Name, "setup")
kp := elgamal.GenKeyPair(group, req.NumOptions)
proofs := make([]zkp.SchnorrProof, req.NumOptions)
wireProofs := make([]wireSchnorr, req.NumOptions)

View file

@ -8,7 +8,6 @@ import (
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/mixnet"
"github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp"
)
@ -47,7 +46,6 @@ type finalResp struct {
// posts its shuffle and decryption proofs to the public transcript (the
// bulletin board), which the verifier re-checks in RunVerify.
func (c *Ceremony) RunTally() error {
trace.Phase("tally")
c.logf("\n--- TALLY PHASE (multi-party) ---")
group := c.Config.Group
@ -138,7 +136,6 @@ func (p *ControlComponent) handleShuffle(env *transport.Envelope) (*transport.En
// Remaining public key = CCs[stage..] + EB, read from the public transcript.
remaining := remainingPK(p.cer.Transcript, req.Stage, cfg.NumCCs)
trace.SetContext(p.id.Name, "tally")
vs := mixnet.GenVerifiableShuffle(in, remaining, group)
// Partial decrypt with this CC's private key; produce decryption proofs.
@ -151,19 +148,6 @@ func (p *ControlComponent) handleShuffle(env *transport.Envelope) (*transport.En
decProofs[i] = zkp.GenDecryptionProof(ct, p.st.keyPair.SK, p.st.keyPair.PK, msg, group)
}
out := elgamal.NewCiphertextVector(decrypted)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Party: p.id.Name,
Kind: trace.KindDecrypt,
Caption: fmt.Sprintf("%s partially decrypts %d ciphertexts and proves correctness", p.id.Name, out.Size()),
LaTeX: `\phi_i' = \phi_i \cdot \gamma_i^{-sk}, \quad \text{proof: } \log_g pk = \log_{\gamma_i} (\gamma_i^{sk})`,
ASCII: "φ'_i = φ_i · γ_i^{-sk} + ZK proof log_g(pk) = log_γ(γ^sk)",
Values: map[string]string{
"count": fmt.Sprintf("%d", out.Size()),
"proof_e": decProofs[0].E.Value().String(),
},
}
})
// Post proofs to the transcript (bulletin board).
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)
@ -187,7 +171,6 @@ func (p *ElectoralBoard) handleFinalMix(env *transport.Envelope) (*transport.Env
return nil, fmt.Errorf("eb final input: %w", err)
}
trace.SetContext(p.id.Name, "tally")
vs := mixnet.GenVerifiableShuffle(in, p.st.keyPair.PK, group)
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)

View file

@ -1,116 +0,0 @@
package party
import (
"testing"
"github.com/user/evote/pkg/trace"
)
// TestCeremonyEmitsLiveCryptoEvents runs the full ceremony with a trace sink
// attached and confirms that the real cryptographic operations emit events
// carrying live runtime values — the foundation of the "watch the math execute"
// cockpit. It checks that each headline operation kind appears with non-empty
// values and correct LaTeX.
func TestCeremonyEmitsLiveCryptoEvents(t *testing.T) {
sink := &trace.SliceSink{}
unsub := trace.Subscribe(sink)
defer unsub()
// 6 voters → N=6 ballots → the shuffle matrix is 2×3 (m>1), so the full
// Bayer-Groth argument tree runs (Hadamard/Zero only fire when m>1).
cfg := testConfig(t, 6, 3)
c, err := NewCeremony(cfg, nil)
if err != nil {
t.Fatalf("NewCeremony: %v", err)
}
if err := c.RunSetup(); err != nil {
t.Fatalf("RunSetup: %v", err)
}
if err := c.RunCards(); err != nil {
t.Fatalf("RunCards: %v", err)
}
if err := c.RunVoting([][]int{{0}, {1}, {2}, {0}, {1}, {2}}); err != nil {
t.Fatalf("RunVoting: %v", err)
}
if err := c.RunTally(); err != nil {
t.Fatalf("RunTally: %v", err)
}
if err := c.RunVerify(); err != nil {
t.Fatalf("RunVerify: %v", err)
}
events := sink.Snapshot()
if len(events) == 0 {
t.Fatal("no trace events emitted during a full ceremony")
}
seen := map[trace.Kind]trace.Event{}
for _, e := range events {
seen[e.Kind] = e
}
// Every headline operation must have fired at least once — including the
// deep Bayer-Groth layers (commitment, sub-arguments, partial decryption)
// that mirror the Swiss Post crypto-primitives class structure.
for _, k := range []trace.Kind{
trace.KindSign, // Ed25519 transport signatures
trace.KindKeyEx, // X25519 ECDH (card delivery)
trace.KindEncrypt, // ballot encryption
trace.KindChallenge, // Fiat-Shamir challenge
trace.KindShuffle, // Bayer-Groth mix-net (top level)
trace.KindCommit, // Pedersen matrix commitment
trace.KindArgument, // a Bayer-Groth sub-argument
trace.KindDecrypt, // CC partial decryption + proof
} {
e, ok := seen[k]
if !ok {
t.Errorf("no %q event emitted", k)
continue
}
if e.LaTeX == "" {
t.Errorf("%q event has empty LaTeX", k)
}
if len(e.Values) == 0 {
t.Errorf("%q event carries no live values", k)
}
}
// All five Bayer-Groth sub-arguments must appear (matching the Swiss Post
// *ArgumentService classes). We identify them by a keyword in the caption.
argCaptions := map[string]bool{}
for _, e := range events {
if e.Kind == trace.KindArgument {
argCaptions[e.Caption] = true
}
}
for _, want := range []string{"Shuffle argument", "Product argument", "Hadamard argument",
"Zero argument", "Single-value product argument", "Multi-exponentiation argument"} {
found := false
for cap := range argCaptions {
if len(cap) >= len(want) && cap[:len(want)] == want {
found = true
break
}
}
if !found {
t.Errorf("sub-argument %q never appeared in the trace", want)
}
}
// Spot-check that the shuffle event reports the padded ballot count (N>=3).
if sh, ok := seen[trace.KindShuffle]; ok {
if sh.Values["N"] == "" {
t.Error("shuffle event missing N")
}
}
// Events must be tagged with a phase, and signatures with the acting party.
if seen[trace.KindSign].Party == "" {
t.Error("signature event not attributed to a party")
}
if seen[trace.KindShuffle].Phase != "tally" {
t.Errorf("shuffle phase = %q, want tally", seen[trace.KindShuffle].Phase)
}
t.Logf("captured %d live crypto events across %d kinds", len(events), len(seen))
}

View file

@ -5,7 +5,6 @@ import (
"github.com/user/evote/pkg/elgamal"
"github.com/user/evote/pkg/mixnet"
"github.com/user/evote/pkg/trace"
)
// RunVerify has the verifier party independently re-check the public transcript:
@ -14,7 +13,6 @@ import (
// decryptions). It returns nil only if every check passes. The verifier holds no
// secret — it works purely from the bulletin-board transcript.
func (c *Ceremony) RunVerify() error {
trace.SetContext(NameVerifier, "verify")
c.logf("\n--- VERIFICATION PHASE (multi-party) ---")
tr := c.Transcript
group := c.Config.Group

View file

@ -7,7 +7,6 @@ import (
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp"
)
@ -42,7 +41,6 @@ type wireBallot struct {
// server routes each ballot to all CCs for proof verification and stores the
// accepted ballots. Selections are provided per voter (indexed by voter).
func (c *Ceremony) RunVoting(selections [][]int) error {
trace.Phase("voting")
c.logf("\n--- VOTING PHASE (multi-party) ---")
for v, voter := range c.Voters {
sel := []int{0}
@ -83,20 +81,6 @@ func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
}
msgRandomness := emath.RandomZqElement(zq)
ct := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(msgElems...)), msgRandomness, p.st.electionPK)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Party: p.id.Name,
Kind: trace.KindEncrypt,
Caption: fmt.Sprintf("%s encrypts ballot (ElGamal over G_q)", p.id.Name),
LaTeX: `E_1 = (\gamma,\, \boldsymbol{\phi}) = \big(g^{r},\; pk^{r}\cdot m\big), \qquad r \xleftarrow{\$} \mathbb{Z}_q`,
ASCII: "E1 = (g^r, pk^r · m), r ← Z_q",
Values: map[string]string{
"r": msgRandomness.Value().String(),
"gamma": ct.Gamma.Value().String(),
"m0": voteElem.Value().String(),
},
}
})
// 2. Verification-card key pair.
p.st.vcSK = emath.RandomZqElement(zq)

View file

@ -1,64 +0,0 @@
package trace
import "sync"
// Short elides a long value for compact display: keeps the first and last few
// characters. Renderers that want the full value use the raw Values map.
func Short(s string) string {
const head, tail = 8, 6
if len(s) <= head+tail+1 {
return s
}
return s[:head] + "…" + s[len(s)-tail:]
}
// ChanSink forwards events to a buffered channel, dropping if the consumer is
// too slow (tracing must never stall the ceremony). Dropped counts are tracked.
type ChanSink struct {
C chan Event
dropped uint64
mu sync.Mutex
}
// NewChanSink creates a ChanSink with the given buffer size.
func NewChanSink(buffer int) *ChanSink {
return &ChanSink{C: make(chan Event, buffer)}
}
func (s *ChanSink) Handle(e Event) {
select {
case s.C <- e:
default:
s.mu.Lock()
s.dropped++
s.mu.Unlock()
}
}
// Dropped returns how many events were dropped due to a full buffer.
func (s *ChanSink) Dropped() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.dropped
}
// SliceSink collects events in memory (used in tests).
type SliceSink struct {
mu sync.Mutex
Events []Event
}
func (s *SliceSink) Handle(e Event) {
s.mu.Lock()
s.Events = append(s.Events, e)
s.mu.Unlock()
}
// Snapshot returns a copy of the collected events.
func (s *SliceSink) Snapshot() []Event {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Event, len(s.Events))
copy(out, s.Events)
return out
}

View file

@ -1,141 +0,0 @@
// Package trace is a live event stream for the cryptographic operations the
// system performs. Each meaningful operation (sampling, encryption, commitment,
// Fiat-Shamir challenge, shuffle, signature, key agreement) emits one Event
// carrying its notation as a LaTeX template plus the REAL runtime values, so a
// renderer can show the mathematics that is executing at the instant it runs.
//
// The stream is surface-agnostic: a terminal view renders ASCII/Unicode, a
// browser view renders typeset LaTeX (KaTeX) — both consume the same events.
//
// Tracing is off by default and has near-zero cost when no sink is attached:
// Emit returns immediately if there are no subscribers. Instrumentation sites
// wrap value formatting in a closure (via EmitFunc) so that formatting work is
// skipped entirely when tracing is off.
package trace
import (
"sync"
"sync/atomic"
)
// Kind categorizes an operation so a renderer can group or icon it.
type Kind string
const (
KindSample Kind = "sample" // random sampling from Z_q / permutations
KindEncrypt Kind = "encrypt" // ElGamal encryption
KindDecrypt Kind = "decrypt" // (partial) decryption
KindExp Kind = "exp" // group exponentiation of note
KindCommit Kind = "commit" // Pedersen commitment
KindChallenge Kind = "challenge" // Fiat-Shamir challenge derivation
KindProof Kind = "proof" // ZK proof generation / verification
KindArgument Kind = "argument" // a Bayer-Groth sub-argument (product/Hadamard/zero/SVP/multi-exp)
KindShuffle Kind = "shuffle" // mix-net permutation + re-encryption
KindSign Kind = "sign" // Ed25519 signature (transport)
KindVerify Kind = "verify" // signature / proof verification
KindKeyEx Kind = "keyex" // X25519 key agreement
KindNote Kind = "note" // phase/step narration, no math
)
// Event is one instrumented operation.
type Event struct {
Seq uint64 `json:"seq"` // monotonic sequence number
Party string `json:"party"` // which stakeholder performed it
Phase string `json:"phase"` // setup / voting / tally / verify
Kind Kind `json:"kind"` // operation category
Caption string `json:"caption"` // one-line human description
LaTeX string `json:"latex"` // notation, with \VAL{name} placeholders
ASCII string `json:"ascii"` // terminal-friendly fallback (optional)
Values map[string]string `json:"values"` // placeholder name -> real runtime value (decimal/hex)
}
// Sink receives events. Implementations must be safe for concurrent use and must
// not block the emitter for long (buffer or drop internally if needed).
type Sink interface {
Handle(Event)
}
var (
mu sync.RWMutex
sinks []Sink
active atomic.Bool // fast path: true when at least one sink is attached
seqCtr atomic.Uint64
curParty atomic.Value // string: default party if an emit omits one
curPhase atomic.Value // string: current phase
)
// Subscribe attaches a sink and returns an unsubscribe function.
func Subscribe(s Sink) func() {
mu.Lock()
sinks = append(sinks, s)
active.Store(true)
mu.Unlock()
return func() {
mu.Lock()
defer mu.Unlock()
for i, x := range sinks {
if x == s {
sinks = append(sinks[:i], sinks[i+1:]...)
break
}
}
active.Store(len(sinks) > 0)
}
}
// Enabled reports whether any sink is attached. Instrumentation can check this
// to skip expensive value formatting.
func Enabled() bool { return active.Load() }
// SetContext sets the default party/phase stamped onto events that omit them.
func SetContext(party, phase string) {
curParty.Store(party)
curPhase.Store(phase)
}
// Phase sets just the current phase.
func Phase(phase string) { curPhase.Store(phase) }
func ctxParty() string {
if v, ok := curParty.Load().(string); ok {
return v
}
return ""
}
func ctxPhase() string {
if v, ok := curPhase.Load().(string); ok {
return v
}
return ""
}
// Emit publishes an event. It fills Seq, and Party/Phase from context if unset.
// Returns immediately when tracing is disabled.
func Emit(e Event) {
if !active.Load() {
return
}
e.Seq = seqCtr.Add(1)
if e.Party == "" {
e.Party = ctxParty()
}
if e.Phase == "" {
e.Phase = ctxPhase()
}
mu.RLock()
current := sinks
mu.RUnlock()
for _, s := range current {
s.Handle(e)
}
}
// EmitFunc builds and emits an event only when tracing is enabled, so callers
// can defer all value formatting into build. Use this at hot instrumentation
// sites where formatting big integers would otherwise cost even when tracing off.
func EmitFunc(build func() Event) {
if !active.Load() {
return
}
Emit(build())
}

View file

@ -1,59 +0,0 @@
package trace
import "testing"
func TestDisabledByDefaultIsCheap(t *testing.T) {
if Enabled() {
t.Fatal("tracing should be off with no sinks")
}
called := false
EmitFunc(func() Event { called = true; return Event{} })
if called {
t.Fatal("EmitFunc built an event while tracing was disabled")
}
}
func TestSubscribeReceivesEvents(t *testing.T) {
sink := &SliceSink{}
unsub := Subscribe(sink)
defer unsub()
SetContext("control-component-0", "tally")
Emit(Event{
Kind: KindChallenge,
Caption: "Fiat-Shamir challenge",
LaTeX: `e = \mathcal{H}(g, y, c) \bmod q`,
Values: map[string]string{"e": "12345678901234567890"},
})
Emit(Event{Party: "voter-0001", Kind: KindEncrypt, Caption: "encrypt ballot"})
got := sink.Snapshot()
if len(got) != 2 {
t.Fatalf("got %d events, want 2", len(got))
}
if got[0].Seq == 0 || got[1].Seq <= got[0].Seq {
t.Fatalf("sequence numbers not monotonic: %d, %d", got[0].Seq, got[1].Seq)
}
if got[0].Party != "control-component-0" || got[0].Phase != "tally" {
t.Fatalf("context not stamped: %+v", got[0])
}
if got[1].Party != "voter-0001" {
t.Fatalf("explicit party overridden: %+v", got[1])
}
unsub()
if Enabled() {
t.Fatal("unsubscribe did not clear the last sink")
}
}
func TestShortElision(t *testing.T) {
if Short("12345") != "12345" {
t.Fatal("short values must pass through")
}
long := "1234567890123456789012345"
e := Short(long)
if e == long || len(e) >= len(long) {
t.Fatalf("long value not elided: %q", e)
}
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"github.com/user/evote/pkg/symmetric"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transportsec"
)
@ -55,26 +54,11 @@ func (id *Identity) NewSecureChannel(peerName string, peerXPub []byte) (*SecureC
if err != nil {
return nil, fmt.Errorf("ECDH %s<->%s: %w", id.Name, peerName, err)
}
sessionKey := deriveSessionKey(shared, id.XPub, peerXPub)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Party: id.Name,
Kind: trace.KindKeyEx,
Caption: fmt.Sprintf("%s ⇄ %s: X25519 key agreement", id.Name, peerName),
LaTeX: `s = a \cdot B = b \cdot A \in \mathbb{X}_{25519}, \qquad k = \mathrm{SHA256}(\text{ctx} \,\Vert\, A \,\Vert\, B \,\Vert\, s)`,
ASCII: "s = a·B = b·A ; k = SHA256(ctx ‖ A ‖ B ‖ s)",
Values: map[string]string{
"peer": peerName,
"shared": hexOf(shared),
"session": hexOf(sessionKey),
},
}
})
return &SecureChannel{
local: id,
peerName: peerName,
peerXPub: peerXPub,
sessionKey: sessionKey,
sessionKey: deriveSessionKey(shared, id.XPub, peerXPub),
}, nil
}

View file

@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transportsec"
)
@ -60,34 +59,9 @@ func (e *Envelope) Seal(sender *Identity) error {
return fmt.Errorf("seal %s->%s: %w", e.From, e.To, err)
}
e.Signature = sig
trace.EmitFunc(func() trace.Event {
return trace.Event{
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}\!\big(\mathrm{SHA256}(\text{envelope})\big),\quad |\sigma| = 64\text{ B}`,
ASCII: "σ ← Ed25519.Sign(sk, H(envelope))",
Values: map[string]string{
"party": e.From,
"to": e.To,
"type": e.Type,
"sigma": hexOf(sig),
},
}
})
return nil
}
func hexOf(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, c := range b {
out[i*2] = hexdigits[c>>4]
out[i*2+1] = hexdigits[c&0x0f]
}
return string(out)
}
// Verify checks the envelope signature against senderEdPub (via Rust).
func (e *Envelope) Verify(senderEdPub []byte) error {
if err := transportsec.Ed25519Verify(senderEdPub, e.signingBytes(), e.Signature); err != nil {

View file

@ -3,7 +3,6 @@ package zkp
import (
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
)
// GenSchnorrProof generates a Schnorr proof of knowledge of discrete log.
@ -68,19 +67,6 @@ func schnorrChallenge(group *emath.GqGroup, y emath.GqElement, c emath.GqElement
hAux,
)
e, _ := emath.NewZqElement(eVal, zqGroup)
trace.EmitFunc(func() trace.Event {
return trace.Event{
Kind: trace.KindChallenge,
Caption: "Fiat-Shamir challenge (Schnorr proof)",
LaTeX: `e = \mathcal{H}\big((p,q,g),\, y,\, c,\, h_{\mathrm{aux}}\big) \bmod q`,
ASCII: "e = H((p,q,g), y, c, h_aux) mod q",
Values: map[string]string{
"e": e.Value().String(),
"y": y.Value().String(),
"c": c.Value().String(),
},
}
})
return e
}

View file

@ -1016,7 +1016,6 @@ r = <span class="red">833704513946265251405968611993787771762...</span>
<span class="cmt">"The output ciphertexts are the correct multi-exponentiations</span>
<span class="cmt"> of the inputs with the permutation matrix as exponents."</span></div>
<div class="analogy-box"><strong>Simplified intuition:</strong> Imagine you have 6 sealed boxes numbered 1-6. You rearrange them and put them in new boxes. The ProductArgument proves "I used each number exactly once" (it's a valid permutation). The MultiExponentiationArgument proves "the new boxes contain the same items as the old boxes" (the ciphertexts were correctly re-encrypted, not swapped for fakes).</div>
<div class="insight-box"><strong>Watch it happen.</strong> Run <code>evote cockpit</code> (browser) or <code>evote cockpit --tmux</code> (terminal): as the mix-net executes, each of these sub-arguments is rendered as live typeset mathematics with the real runtime values, in the order it is constructed &mdash; the Pedersen commitment to the permutation matrix, then the Product / Hadamard / Zero / SingleValueProduct / MultiExponentiation arguments, then the partial decryptions. The tree above is not a diagram of the code; it <em>is</em> the code, narrating itself.</div>
`
},