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