From a5b9ce8ea333a00a23f3e8bf73b54fef8825ef3a Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Tue, 7 Jul 2026 13:59:52 +0200 Subject: [PATCH] Add pkg/trace: surface-agnostic live crypto-event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/trace/sink.go | 64 ++++++++++++++++++ pkg/trace/trace.go | 140 ++++++++++++++++++++++++++++++++++++++++ pkg/trace/trace_test.go | 59 +++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 pkg/trace/sink.go create mode 100644 pkg/trace/trace.go create mode 100644 pkg/trace/trace_test.go diff --git a/pkg/trace/sink.go b/pkg/trace/sink.go new file mode 100644 index 0000000..92984c7 --- /dev/null +++ b/pkg/trace/sink.go @@ -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 +} diff --git a/pkg/trace/trace.go b/pkg/trace/trace.go new file mode 100644 index 0000000..02df73e --- /dev/null +++ b/pkg/trace/trace.go @@ -0,0 +1,140 @@ +// 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 + 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()) +} diff --git a/pkg/trace/trace_test.go b/pkg/trace/trace_test.go new file mode 100644 index 0000000..a93dc02 --- /dev/null +++ b/pkg/trace/trace_test.go @@ -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) + } +}