Compare commits

...

10 commits

Author SHA1 Message Date
a1e4ec6ce7 Cockpit UX: auto-open browser, Replay button, graceful serialization
Make the live cockpit foolproof to run:
- Auto-open the default browser on start (macOS/Linux/Windows; --no-open to
  disable). Binds the listener first, so the browser only opens once the server
  is accepting connections.
- Clear message when the port is already in use instead of a raw bind error.
- A prominent "▶ Run the election again" button appears when the ceremony
  finishes, so replaying needs no arcane page-reload knowledge.
- Second viewer now waits gracefully (mutex) instead of getting a 409.

Verified in-browser: replay button shows on completion; port-busy prints a
friendly message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:13:20 +02:00
e8b4247089 Docs + lecture: the cockpit renders the Bayer-Groth tree live
README cockpit section now lists the deep layers it instruments, named after the
Swiss Post crypto-primitives classes (CommitmentService, the five *ArgumentService
classes, DecryptionProofService).

crypto.html: on the slide that teaches the Bayer-Groth sub-argument tree, add a
callout that `evote cockpit` renders exactly that tree as live typeset math in
construction order — "the tree is not a diagram of the code; it is the code
narrating itself." Root standalone copy synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:07 +02:00
31e9a25bb8 Cockpit MathML converter: sums, products, dots; verified in browser
Extend the LaTeX→MathML converter for the deep-instrumentation notation:
big operators ∏ ∑ with msubsup limits (\sum_{i=1}^{m}), ∘ ∗ ∼ log, and the
dots family (\cdots ⋯, \ldots …, \vdots, \ddots). Handle _{}^{} pairs as
msubsup and ignore \textstyle/\Bigg.

Trim the shuffle-argument template so the core relation fits without wrapping.

Verified in a real browser (6-voter run, full 2×3 shuffle matrix so every
sub-argument fires): Pedersen commitment, all five Bayer-Groth sub-arguments
(shuffle, product, Hadamard, zero, SVP, multi-exp), and partial decryption all
render as clean typeset math with live values — no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:31:43 +02:00
c24650885e Instrument the deep Bayer-Groth layers: commitments, sub-arguments, decryption
Take the live-math cockpit down to the level of the Swiss Post crypto-primitives
class structure. The shuffle proof is no longer one line — you can now watch it
being constructed:

- Pedersen matrix commitment (CommitmentService analog): c_A = Comm(A; r),
  c_{A,j} = h^{r_j} Π g_i^{A_ij}, emitted from CommitMatrix.
- All five Bayer-Groth sub-arguments, mirroring the *ArgumentService classes:
  ShuffleArgument (composition + x,y,z challenges), ProductArgument,
  HadamardArgument (entrywise product), ZeroArgument (bilinear star-map),
  SingleValueProductArgument, MultiExponentiationArgument — each emits its
  defining relation as LaTeX with live dimensions.
- Partial decryption + decryption proof (DecryptionProofService analog):
  φ'_i = φ_i·γ_i^{-sk} with the ZK proof that log_g(pk) = log_γ(γ^sk).

New trace.KindArgument. Low-level Commit stays uninstrumented (called in
verification too — would flood the stream); CommitMatrix is the semantic step.

Test: a 6-voter ceremony (N=6 → 2×3 shuffle matrix, so m>1 and the full argument
tree runs) captures 345 live events across 8 kinds, and asserts all five named
sub-arguments appear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:26:03 +02:00
1ad12d6c33 README: document both cockpit surfaces (browser + tmux)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:43:45 +02:00
b38b990bc4 Add evote cockpit --tmux: one terminal pane per stakeholder
The terminal systems-view half of the cockpit, fed by the SAME trace stream as
the browser math view — no double-maintenance.

- cockpit_tmux.go: spawns a tmux session with one pane per stakeholder (setup,
  CC0–CC3, electoral board, voting server, verifier, and an aggregate voters
  pane), each running `evote panelview`. The ceremony runs in the background,
  appending paced NDJSON events to a shared temp file the panes tail. Guards for
  missing tmux and for being run inside an existing tmux session.
- panelview.go: a hidden subcommand each pane runs — follows the shared event
  file, filters to its stakeholder, and renders that party's operations as
  colored ASCII/Unicode math with elided live values.
- Attribute setup-phase Schnorr challenges to the generating CC and verify-phase
  challenges to the verifier (trace.SetContext), so every pane gets its ops.

Verified: panelview filters and renders correctly per role; a 9-pane tiled tmux
session builds with the real pane commands. (Interactive attach needs a TTY, so
the live attach is exercised when you run it.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:43:05 +02:00
e84d3ecb41 README: document the evote cockpit live-math teaching view
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:20:16 +02:00
1e0b286bd5 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>
2026-07-07 14:19:13 +02:00
bc43c3d3aa Instrument real crypto ops to emit live LaTeX events
The running cryptography now narrates itself. Each headline operation emits a
trace.Event carrying its LaTeX notation plus the actual runtime values, the
instant it executes:

- Ed25519 signature on every inter-party message (envelope.Seal)
- X25519 ECDH key agreement for confidential card delivery (NewSecureChannel)
- ElGamal ballot encryption E1 = (g^r, pk^r·m) with the real r (castBallot)
- Fiat-Shamir challenge e = H(...) mod q (Schnorr proof)
- Bayer-Groth verifiable shuffle C' = {ReEnc_pk(C_π(i))} with N (mix-net)

Ceremony phases set trace phase/party context so events are attributed to the
acting stakeholder and phase. Instrumentation is behind the enabled-check, so
normal runs pay nothing.

Test: a full traced ceremony captures 184 live events across all five headline
kinds, each with non-empty LaTeX and live values, correctly phase/party-tagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:06:08 +02:00
a5b9ce8ea3 Add pkg/trace: surface-agnostic live crypto-event stream
The foundation for the "watch the mathematics execute" cockpit. Each meaningful
crypto operation will emit one Event carrying its LaTeX notation plus the real
runtime values, tagged with party + phase. One stream feeds both a terminal view
(ASCII/Unicode) and a browser view (typeset KaTeX) — no double-maintenance.

- Off by default with near-zero cost: Emit/EmitFunc return immediately when no
  sink is attached (atomic fast path), so instrumentation is free in normal runs.
- Sinks: ChanSink (buffered, drops rather than stalling the ceremony), SliceSink
  (tests). Short() elides ~77-digit values for compact display.

Tests: disabled-is-cheap, monotonic sequence + context stamping, elision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:59:52 +02:00
29 changed files with 1384 additions and 2 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 build artifacts (the static lib is rebuilt via `make rust` / `cargo build`)
rust/**/target/ rust/**/target/
# Local Claude Code settings (keep launch.json, ignore per-user settings)
.claude/settings.local.json
# macOS # macOS
.DS_Store .DS_Store

View file

@ -68,6 +68,10 @@ make test # runs cargo test + go test ./...
./evote netdemo --voters 10 --options 3 ./evote netdemo --voters 10 --options 3
./evote netdemo --voters 3 --options 2 --verbose # log every signed message ./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) # Serve presentations on local network (for iPad viewing)
./evote serve --port 8080 ./evote serve --port 8080
@ -75,6 +79,30 @@ make test # runs cargo test + go test ./...
./evote present ./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 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 **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 own private state, and every message between them is Ed25519-signed (and, for

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

@ -0,0 +1,220 @@
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()
}

113
cmd/evote/cockpit_tmux.go Normal file
View file

@ -0,0 +1,113 @@
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)
}
}

156
cmd/evote/panelview.go Normal file
View file

@ -0,0 +1,156 @@
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)
}
}

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

@ -0,0 +1,279 @@
<!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,6 +1016,7 @@ r = <span class="red">833704513946265251405968611993787771762...</span>
<span class="cmt">"The output ciphertexts are the correct multi-exponentiations</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> <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="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,10 +1,12 @@
package mixnet package mixnet
import ( import (
"fmt"
"math/big" "math/big"
"github.com/user/evote/pkg/hash" "github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math" emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
) )
var oneBI = big.NewInt(1) var oneBI = big.NewInt(1)
@ -101,5 +103,19 @@ func (ck CommitmentKey) CommitMatrix(A *emath.ZqMatrix, r *emath.ZqVector) *emat
for j := 0; j < A.NumCols(); j++ { for j := 0; j < A.NumCols(); j++ {
commitments[j] = ck.Commit(A.GetColumn(j), r.Get(j)) commitments[j] = ck.Commit(A.GetColumn(j), r.Get(j))
} }
return emath.GqVectorOf(commitments...) 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
} }

View file

@ -30,6 +30,12 @@ func GenHadamardArgument(
n := A.NumRows() n := A.NumRows()
m := A.NumCols() 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) // 1. Compute intermediate products b_j = ∏_{i=0}^j A[:,i] (entrywise)
bIntermediate := make([]*emath.ZqVector, m) bIntermediate := make([]*emath.ZqVector, m)
bIntermediate[0] = A.GetColumn(0) bIntermediate[0] = A.GetColumn(0)

View file

@ -37,6 +37,12 @@ func GenMultiExponentiationArgument(
m := A.NumCols() m := A.NumCols()
zero, _ := emath.NewZqElement(big.NewInt(0), zqGroup) 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 // 1. Random values
a0 := emath.RandomZqVector(n, zqGroup) a0 := emath.RandomZqVector(n, zqGroup)
r0 := emath.RandomZqElement(zqGroup) r0 := emath.RandomZqElement(zqGroup)

View file

@ -27,6 +27,12 @@ func GenProductArgument(
n := A.NumRows() n := A.NumRows()
m := A.NumCols() 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 { if m == 1 {
// Single column: just use SVP directly // Single column: just use SVP directly
svp := GenSingleValueProductArgument(cA.Get(0), b, A.GetColumn(0), r.Get(0), pk, ck, group) svp := GenSingleValueProductArgument(cA.Get(0), b, A.GetColumn(0), r.Get(0), pk, ck, group)

View file

@ -30,6 +30,12 @@ func GenShuffleArgument(
N := C.Size() N := C.Size()
m, n := GetMatrixDimensions(N) 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 // 1. Convert permutation to m×n matrix A
aCols := make([]*emath.ZqVector, m) aCols := make([]*emath.ZqVector, m)
rA := emath.RandomZqVector(m, zqGroup) rA := emath.RandomZqVector(m, zqGroup)

View file

@ -1,6 +1,7 @@
package mixnet package mixnet
import ( import (
"fmt"
"math/big" "math/big"
"github.com/user/evote/pkg/elgamal" "github.com/user/evote/pkg/elgamal"
@ -33,6 +34,12 @@ func GenSingleValueProductArgument(
n := a.Size() n := a.Size()
zero, _ := emath.NewZqElement(big.NewInt(0), zqGroup) 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 // 1. Compute partial products b_k = Π_{i=0}^k a_i
bPartial := make([]emath.ZqElement, n) bPartial := make([]emath.ZqElement, n)
bPartial[0] = a.Get(0) bPartial[0] = a.Get(0)

26
pkg/mixnet/trace_hooks.go Normal file
View file

@ -0,0 +1,26 @@
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,8 +1,11 @@
package mixnet package mixnet
import ( import (
"fmt"
"github.com/user/evote/pkg/elgamal" "github.com/user/evote/pkg/elgamal"
emath "github.com/user/evote/pkg/math" emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
) )
// VerifiableShuffle holds the result of a verifiable shuffle. // VerifiableShuffle holds the result of a verifiable shuffle.
@ -30,6 +33,20 @@ func GenVerifiableShuffle(
// Generate shuffle argument // Generate shuffle argument
arg := GenShuffleArgument(C, shuffle.Shuffled, shuffle.Perm, shuffle.Rho, pk, ck, group) 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{ return VerifiableShuffle{
ShuffledCiphertexts: shuffle.Shuffled, ShuffledCiphertexts: shuffle.Shuffled,
Argument: arg, Argument: arg,

View file

@ -37,6 +37,12 @@ func GenZeroArgument(
n := A.NumRows() n := A.NumRows()
m := A.NumCols() 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 // 1. Prepend random a_0 to A, append random b_m to B
a0 := emath.RandomZqVector(n, zqGroup) a0 := emath.RandomZqVector(n, zqGroup)
r0 := emath.RandomZqElement(zqGroup) r0 := emath.RandomZqElement(zqGroup)

View file

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

View file

@ -8,6 +8,7 @@ import (
emath "github.com/user/evote/pkg/math" emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/mixnet" "github.com/user/evote/pkg/mixnet"
"github.com/user/evote/pkg/returncodes" "github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transport" "github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp" "github.com/user/evote/pkg/zkp"
) )
@ -46,6 +47,7 @@ type finalResp struct {
// posts its shuffle and decryption proofs to the public transcript (the // posts its shuffle and decryption proofs to the public transcript (the
// bulletin board), which the verifier re-checks in RunVerify. // bulletin board), which the verifier re-checks in RunVerify.
func (c *Ceremony) RunTally() error { func (c *Ceremony) RunTally() error {
trace.Phase("tally")
c.logf("\n--- TALLY PHASE (multi-party) ---") c.logf("\n--- TALLY PHASE (multi-party) ---")
group := c.Config.Group group := c.Config.Group
@ -136,6 +138,7 @@ func (p *ControlComponent) handleShuffle(env *transport.Envelope) (*transport.En
// Remaining public key = CCs[stage..] + EB, read from the public transcript. // Remaining public key = CCs[stage..] + EB, read from the public transcript.
remaining := remainingPK(p.cer.Transcript, req.Stage, cfg.NumCCs) remaining := remainingPK(p.cer.Transcript, req.Stage, cfg.NumCCs)
trace.SetContext(p.id.Name, "tally")
vs := mixnet.GenVerifiableShuffle(in, remaining, group) vs := mixnet.GenVerifiableShuffle(in, remaining, group)
// Partial decrypt with this CC's private key; produce decryption proofs. // Partial decrypt with this CC's private key; produce decryption proofs.
@ -148,6 +151,19 @@ func (p *ControlComponent) handleShuffle(env *transport.Envelope) (*transport.En
decProofs[i] = zkp.GenDecryptionProof(ct, p.st.keyPair.SK, p.st.keyPair.PK, msg, group) decProofs[i] = zkp.GenDecryptionProof(ct, p.st.keyPair.SK, p.st.keyPair.PK, msg, group)
} }
out := elgamal.NewCiphertextVector(decrypted) 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). // Post proofs to the transcript (bulletin board).
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs) p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)
@ -171,6 +187,7 @@ func (p *ElectoralBoard) handleFinalMix(env *transport.Envelope) (*transport.Env
return nil, fmt.Errorf("eb final input: %w", err) return nil, fmt.Errorf("eb final input: %w", err)
} }
trace.SetContext(p.id.Name, "tally")
vs := mixnet.GenVerifiableShuffle(in, p.st.keyPair.PK, group) vs := mixnet.GenVerifiableShuffle(in, p.st.keyPair.PK, group)
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs) p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)

116
pkg/party/trace_test.go Normal file
View file

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

View file

@ -7,6 +7,7 @@ import (
"github.com/user/evote/pkg/hash" "github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math" emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/returncodes" "github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transport" "github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp" "github.com/user/evote/pkg/zkp"
) )
@ -41,6 +42,7 @@ type wireBallot struct {
// server routes each ballot to all CCs for proof verification and stores the // server routes each ballot to all CCs for proof verification and stores the
// accepted ballots. Selections are provided per voter (indexed by voter). // accepted ballots. Selections are provided per voter (indexed by voter).
func (c *Ceremony) RunVoting(selections [][]int) error { func (c *Ceremony) RunVoting(selections [][]int) error {
trace.Phase("voting")
c.logf("\n--- VOTING PHASE (multi-party) ---") c.logf("\n--- VOTING PHASE (multi-party) ---")
for v, voter := range c.Voters { for v, voter := range c.Voters {
sel := []int{0} sel := []int{0}
@ -81,6 +83,20 @@ func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
} }
msgRandomness := emath.RandomZqElement(zq) msgRandomness := emath.RandomZqElement(zq)
ct := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(msgElems...)), msgRandomness, p.st.electionPK) 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. // 2. Verification-card key pair.
p.st.vcSK = emath.RandomZqElement(zq) p.st.vcSK = emath.RandomZqElement(zq)

64
pkg/trace/sink.go Normal file
View file

@ -0,0 +1,64 @@
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
}

141
pkg/trace/trace.go Normal file
View file

@ -0,0 +1,141 @@
// 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())
}

59
pkg/trace/trace_test.go Normal file
View file

@ -0,0 +1,59 @@
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,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/user/evote/pkg/symmetric" "github.com/user/evote/pkg/symmetric"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transportsec" "github.com/user/evote/pkg/transportsec"
) )
@ -54,11 +55,26 @@ func (id *Identity) NewSecureChannel(peerName string, peerXPub []byte) (*SecureC
if err != nil { if err != nil {
return nil, fmt.Errorf("ECDH %s<->%s: %w", id.Name, peerName, err) 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{ return &SecureChannel{
local: id, local: id,
peerName: peerName, peerName: peerName,
peerXPub: peerXPub, peerXPub: peerXPub,
sessionKey: deriveSessionKey(shared, id.XPub, peerXPub), sessionKey: sessionKey,
}, nil }, nil
} }

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/user/evote/pkg/trace"
"github.com/user/evote/pkg/transportsec" "github.com/user/evote/pkg/transportsec"
) )
@ -59,9 +60,34 @@ func (e *Envelope) Seal(sender *Identity) error {
return fmt.Errorf("seal %s->%s: %w", e.From, e.To, err) return fmt.Errorf("seal %s->%s: %w", e.From, e.To, err)
} }
e.Signature = sig 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 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). // Verify checks the envelope signature against senderEdPub (via Rust).
func (e *Envelope) Verify(senderEdPub []byte) error { func (e *Envelope) Verify(senderEdPub []byte) error {
if err := transportsec.Ed25519Verify(senderEdPub, e.signingBytes(), e.Signature); err != nil { if err := transportsec.Ed25519Verify(senderEdPub, e.signingBytes(), e.Signature); err != nil {

View file

@ -3,6 +3,7 @@ package zkp
import ( import (
"github.com/user/evote/pkg/hash" "github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math" emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/trace"
) )
// GenSchnorrProof generates a Schnorr proof of knowledge of discrete log. // GenSchnorrProof generates a Schnorr proof of knowledge of discrete log.
@ -67,6 +68,19 @@ func schnorrChallenge(group *emath.GqGroup, y emath.GqElement, c emath.GqElement
hAux, hAux,
) )
e, _ := emath.NewZqElement(eVal, zqGroup) 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 return e
} }

View file

@ -1016,6 +1016,7 @@ r = <span class="red">833704513946265251405968611993787771762...</span>
<span class="cmt">"The output ciphertexts are the correct multi-exponentiations</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> <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="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>
` `
}, },