swisspost-evoting-go-poc/pkg/party/tally.go
saymrwulf 23723fddd6 Tally + verification: full multi-party ceremony runs end-to-end
The mix-net now runs across separate parties over the signed transport: the
server pads the ballot box and hands it to CC0; each CC shuffles + partially
decrypts and passes the (validated) ciphertexts to the next; the electoral
board performs the final shuffle + decryption. Ciphertext handoffs cross the
authenticated transport; each party posts its shuffle and decryption proofs to
the public transcript (the bulletin board).

- tally.go: RunTally orchestration + per-party handlers (server pad, CC shuffle,
  EB final decrypt). Persists the padded mix input and per-stage partial
  decrypts to the transcript (fixes F7/F8 in the multi-party setting).
- verify.go: RunVerify has the verifier independently re-check every CC Schnorr
  proof and the whole shuffle chain from the transcript alone (no secrets).
- returncodes: DecodeVoteChecked returns an error instead of panicking on a
  non-smooth plaintext (fixes F12), used on the tally path so a corrupt ballot
  is counted as spoiled rather than crashing the tally.

Tests: the full ceremony (setup -> cards -> voting -> tally -> verify) produces
the correct tally over 124 verified transport messages; the verifier rejects a
transcript with swapped Schnorr proofs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:23:56 +02:00

218 lines
7.6 KiB
Go

package party
import (
"fmt"
"math/big"
"github.com/user/evote/pkg/elgamal"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/mixnet"
"github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp"
)
// Tally-phase message types.
const (
MsgStartTally = "start-tally" // ceremony -> server: produce padded mix input
MsgMixInput = "mix-input" // server -> ceremony: padded ciphertexts
MsgShuffle = "shuffle" // ceremony -> CCj: shuffle + partial decrypt
MsgShuffled = "shuffled" // CCj -> ceremony: partially decrypted ciphertexts
MsgFinalMix = "final-mix" // ceremony -> EB: final shuffle + decrypt
MsgFinalDone = "final-done" // EB -> ceremony
)
type mixInputPayload struct {
Cts wireCiphertextVector `json:"cts"`
}
type shuffleReq struct {
Stage int `json:"stage"` // CC index driving the remaining-key computation
Cts wireCiphertextVector `json:"cts"`
}
type shuffleResp struct {
Cts wireCiphertextVector `json:"cts"` // partially decrypted ciphertexts
}
type finalResp struct {
Result map[int]int `json:"result"`
}
// RunTally drives the mix-net: the server pads the ballot box and hands it to
// CC0; each CC shuffles + partially decrypts and passes the result to the next;
// the electoral board performs the final shuffle + decryption. Ciphertext
// handoffs travel over the signed transport (validated on decode); each party
// 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 {
c.logf("\n--- TALLY PHASE (multi-party) ---")
group := c.Config.Group
// 1. Ask the server for the padded mix input.
env, err := c.send(c.Verifier.id, NameServer, MsgStartTally, struct{}{})
if err != nil {
return fmt.Errorf("start tally: %w", err)
}
var mi mixInputPayload
if err := transport.UnmarshalPayload(env.Payload, &mi); err != nil {
return err
}
cts, err := mi.Cts.decode(group)
if err != nil {
return fmt.Errorf("mix input: %w", err)
}
c.Transcript.MixInput = cts
c.Transcript.Shuffles = nil
c.Transcript.PartialDecrypts = nil
c.Transcript.DecryptProofs = nil
c.logf(" tally: mixing %d ciphertexts through %d CCs + EB", cts.Size(), c.Config.NumCCs)
// 2. Chain through the control components.
for j := 0; j < c.Config.NumCCs; j++ {
env, err := c.send(c.Verifier.id, CCName(j), MsgShuffle, shuffleReq{
Stage: j, Cts: encodeCiphertextVector(cts),
})
if err != nil {
return fmt.Errorf("cc%d shuffle: %w", j, err)
}
var resp shuffleResp
if err := transport.UnmarshalPayload(env.Payload, &resp); err != nil {
return err
}
cts, err = resp.Cts.decode(group)
if err != nil {
return fmt.Errorf("cc%d output: %w", j, err)
}
c.logf(" tally: cc%d shuffled + partially decrypted %d ciphertexts", j, cts.Size())
}
// 3. Electoral board: final shuffle + full decryption.
env, err = c.send(c.Verifier.id, NameEB, MsgFinalMix, shuffleReq{Cts: encodeCiphertextVector(cts)})
if err != nil {
return fmt.Errorf("eb final mix: %w", err)
}
var fr finalResp
if err := transport.UnmarshalPayload(env.Payload, &fr); err != nil {
return err
}
c.Transcript.Result = fr.Result
c.logf(" tally: electoral board decrypted final plaintexts; result posted to transcript")
return nil
}
// handleStartTally (server) pads the ballot box to N>=2 and returns the mix
// input, persisting it so the verifier can check shuffle 0 against it.
func (p *VotingServer) handleStartTally(env *transport.Envelope) (*transport.Envelope, error) {
group := p.cer.Config.Group
zq := emath.ZqGroupFromGqGroup(group)
cts := make([]elgamal.Ciphertext, len(p.st.ballotBox))
for i, b := range p.st.ballotBox {
cts[i] = b.Ciphertext
}
vec := elgamal.NewCiphertextVector(cts)
for vec.Size() < 2 {
trivial := elgamal.EncryptOnes(emath.RandomZqElement(zq), p.st.electionPK)
vec = vec.Append(trivial)
}
return reply(p.id, env.From, MsgMixInput, env.Nonce, mixInputPayload{Cts: encodeCiphertextVector(vec)})
}
// handleShuffle (CC) shuffles + partially decrypts, posts proofs to the
// transcript, and returns the partially decrypted ciphertexts.
func (p *ControlComponent) handleShuffle(env *transport.Envelope) (*transport.Envelope, error) {
var req shuffleReq
if err := transport.UnmarshalPayload(env.Payload, &req); err != nil {
return nil, err
}
cfg := p.cer.Config
group := cfg.Group
in, err := req.Cts.decode(group)
if err != nil {
return nil, fmt.Errorf("cc%d shuffle input: %w", p.index, err)
}
// Remaining public key = CCs[stage..] + EB, read from the public transcript.
remaining := remainingPK(p.cer.Transcript, req.Stage, cfg.NumCCs)
vs := mixnet.GenVerifiableShuffle(in, remaining, group)
// Partial decrypt with this CC's private key; produce decryption proofs.
decrypted := make([]elgamal.Ciphertext, vs.ShuffledCiphertexts.Size())
decProofs := make([]zkp.DecryptionProof, vs.ShuffledCiphertexts.Size())
for i := 0; i < vs.ShuffledCiphertexts.Size(); i++ {
ct := vs.ShuffledCiphertexts.Get(i)
decrypted[i] = elgamal.PartialDecrypt(ct, p.st.keyPair.SK)
msg := elgamal.Decrypt(ct, p.st.keyPair.SK)
decProofs[i] = zkp.GenDecryptionProof(ct, p.st.keyPair.SK, p.st.keyPair.PK, msg, group)
}
out := elgamal.NewCiphertextVector(decrypted)
// Post proofs to the transcript (bulletin board).
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)
p.cer.Transcript.PartialDecrypts = append(p.cer.Transcript.PartialDecrypts, out)
p.cer.Transcript.DecryptProofs = append(p.cer.Transcript.DecryptProofs, decProofs)
return reply(p.id, env.From, MsgShuffled, env.Nonce, shuffleResp{Cts: encodeCiphertextVector(out)})
}
// handleFinalMix (EB) performs the final shuffle and full decryption, posts the
// shuffle to the transcript, decodes plaintexts, and returns the tally.
func (p *ElectoralBoard) handleFinalMix(env *transport.Envelope) (*transport.Envelope, error) {
var req shuffleReq
if err := transport.UnmarshalPayload(env.Payload, &req); err != nil {
return nil, err
}
cfg := p.cer.Config
group := cfg.Group
in, err := req.Cts.decode(group)
if err != nil {
return nil, fmt.Errorf("eb final input: %w", err)
}
vs := mixnet.GenVerifiableShuffle(in, p.st.keyPair.PK, group)
p.cer.Transcript.Shuffles = append(p.cer.Transcript.Shuffles, vs)
// Full decryption and decode.
plaintexts := make([]*emath.GqVector, vs.ShuffledCiphertexts.Size())
result := make(map[int]int)
primes := transcriptPrimes(p.cer.Transcript)
for i := 0; i < vs.ShuffledCiphertexts.Size(); i++ {
msg := elgamal.Decrypt(vs.ShuffledCiphertexts.Get(i), p.st.keyPair.SK)
plaintexts[i] = msg.Elements
first := msg.Get(0)
if first.IsIdentity() {
continue // padding ciphertext
}
selected, err := returncodes.DecodeVoteChecked(first.Value(), primes)
if err != nil {
// A ballot that does not decode is counted as spoiled, not fatal.
continue
}
for _, opt := range selected {
result[opt]++
}
}
p.cer.Transcript.FinalPlaintexts = plaintexts
return reply(p.id, env.From, MsgFinalDone, env.Nonce, finalResp{Result: result})
}
// remainingPK combines CCs[stage..NumCCs) + EB public keys from the transcript.
func remainingPK(tr *PublicTranscript, stage, numCCs int) elgamal.PublicKey {
pks := make([]elgamal.PublicKey, 0, numCCs-stage+1)
for k := stage; k < numCCs; k++ {
pks = append(pks, tr.CCElectionPKs[k])
}
pks = append(pks, tr.EBPublicKey)
return elgamal.CombinePublicKeys(pks...)
}
func transcriptPrimes(tr *PublicTranscript) []*big.Int {
out := make([]*big.Int, len(tr.Primes))
for i, s := range tr.Primes {
out[i], _ = new(big.Int).SetString(s, 10)
}
return out
}