mirror of
https://github.com/saymrwulf/swisspost-evoting-go-poc.git
synced 2026-09-04 20:23:55 +00:00
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>
218 lines
7.6 KiB
Go
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
|
|
}
|