mirror of
https://github.com/saymrwulf/swisspost-evoting-go-poc.git
synced 2026-09-03 20:13:43 +00:00
Compare commits
10 commits
cad6e112e3
...
a1e4ec6ce7
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e4ec6ce7 | |||
| e8b4247089 | |||
| 31e9a25bb8 | |||
| c24650885e | |||
| 1ad12d6c33 | |||
| b38b990bc4 | |||
| e84d3ecb41 | |||
| 1e0b286bd5 | |||
| bc43c3d3aa | |||
| a5b9ce8ea3 |
29 changed files with 1384 additions and 2 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "cockpit",
|
||||
"runtimeExecutable": "./evote",
|
||||
"runtimeArgs": ["cockpit", "--port", "8092", "--voters", "2", "--options", "3", "--delay", "40"],
|
||||
"port": 8092
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,5 +8,8 @@ testdata/*.json
|
|||
# Rust build artifacts (the static lib is rebuilt via `make rust` / `cargo build`)
|
||||
rust/**/target/
|
||||
|
||||
# Local Claude Code settings (keep launch.json, ignore per-user settings)
|
||||
.claude/settings.local.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -68,6 +68,10 @@ 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
|
||||
|
||||
|
|
@ -75,6 +79,30 @@ 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
|
||||
|
|
|
|||
220
cmd/evote/cockpit.go
Normal file
220
cmd/evote/cockpit.go
Normal 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
113
cmd/evote/cockpit_tmux.go
Normal 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
156
cmd/evote/panelview.go
Normal 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
279
cmd/evote/web/cockpit.html
Normal 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
|
|
@ -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"> 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 — 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>
|
||||
`
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
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)
|
||||
|
|
@ -101,5 +103,19 @@ 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))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package mixnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/user/evote/pkg/elgamal"
|
||||
|
|
@ -33,6 +34,12 @@ 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)
|
||||
|
|
|
|||
26
pkg/mixnet/trace_hooks.go
Normal file
26
pkg/mixnet/trace_hooks.go
Normal 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)}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
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.
|
||||
|
|
@ -30,6 +33,20 @@ 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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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"
|
||||
)
|
||||
|
|
@ -51,6 +52,7 @@ 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).
|
||||
|
|
@ -168,6 +170,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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"
|
||||
)
|
||||
|
|
@ -46,6 +47,7 @@ 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
|
||||
|
||||
|
|
@ -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 := 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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
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)
|
||||
|
|
@ -171,6 +187,7 @@ 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)
|
||||
|
||||
|
|
|
|||
116
pkg/party/trace_test.go
Normal file
116
pkg/party/trace_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ 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:
|
||||
|
|
@ -13,6 +14,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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"
|
||||
)
|
||||
|
|
@ -41,6 +42,7 @@ 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}
|
||||
|
|
@ -81,6 +83,20 @@ 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)
|
||||
|
|
|
|||
64
pkg/trace/sink.go
Normal file
64
pkg/trace/sink.go
Normal 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
141
pkg/trace/trace.go
Normal 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
59
pkg/trace/trace_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/user/evote/pkg/symmetric"
|
||||
"github.com/user/evote/pkg/trace"
|
||||
"github.com/user/evote/pkg/transportsec"
|
||||
)
|
||||
|
||||
|
|
@ -54,11 +55,26 @@ 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: deriveSessionKey(shared, id.XPub, peerXPub),
|
||||
sessionKey: sessionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/user/evote/pkg/trace"
|
||||
"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)
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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.
|
||||
|
|
@ -67,6 +68,19 @@ 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"> 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 — 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>
|
||||
`
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue