swisspost-evoting-go-poc/pkg/party/voting.go
saymrwulf a443e44875 Voting phase: ballot submission with cross-party proof verification
Voters encrypt their selection under the election key, build the (sound)
exponentiation proof binding the ballot to their verification-card key, and
submit to the voting server. The server validates every group element on
receipt, routes the ballot to all four CCs for proof verification, and stores
it only on unanimous acceptance — persisting vcPK (finding F6) so the proof
statement is reconstructible by any party.

- voting.go: castBallot (voter), handleCastBallot (server), handleVerifyBallot
  (CC). The CC re-derives the proof statement and verifies it; a malformed proof
  or bad group element yields a clean reject, never a panic (the trust-boundary
  hardening deferred from the due-diligence pass).
- wire.go: exponentiation-proof DTO.

Tests: 4 ballots flow end-to-end and are stored with vcPK; a ballot with a
zeroed proof is rejected by the CCs and never stored.

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

209 lines
7.2 KiB
Go

package party
import (
"fmt"
"github.com/user/evote/pkg/elgamal"
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
"github.com/user/evote/pkg/returncodes"
"github.com/user/evote/pkg/transport"
"github.com/user/evote/pkg/zkp"
)
// Voting-phase message types.
const (
MsgCastBallot = "cast-ballot" // voter -> server
MsgBallotStored = "ballot-stored" // server -> voter
MsgVerifyBallot = "verify-ballot" // server -> CCj
MsgBallotVerdict = "ballot-verdict"
)
// wireBallot is a submitted ballot. It carries the encrypted vote, the
// exponentiated ciphertext, the verification-card public key (persisted so a
// remote party can reconstruct the proof statement — finding F6), and the
// exponentiation proof binding vcPK to the exponentiation.
type wireBallot struct {
VoterID string `json:"voter_id"`
VcID string `json:"vc_id"`
Ciphertext wireCiphertext `json:"ciphertext"`
ExponentiatedG string `json:"exp_gamma"`
ExponentiatedP string `json:"exp_phi0"`
VcPK string `json:"vc_pk"`
ExpProof wireSchnorr `json:"exp_proof"`
}
// RunVoting has every voter encrypt its selection and submit a ballot; the
// 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 {
c.logf("\n--- VOTING PHASE (multi-party) ---")
for v, voter := range c.Voters {
sel := []int{0}
if v < len(selections) && len(selections[v]) > 0 {
sel = selections[v]
}
if _, err := voter.castBallot(sel); err != nil {
return fmt.Errorf("voter %d cast: %w", v, err)
}
}
c.logf(" voting: %d ballots submitted and accepted", len(c.Voters))
return nil
}
// castBallot (voter) encrypts the selection, builds the exponentiation proof,
// and submits the ballot to the server.
func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
cfg := p.cer.Config
group := cfg.Group
zq := emath.ZqGroupFromGqGroup(group)
if p.st.card == nil {
return nil, fmt.Errorf("%s has no voting card", p.id.Name)
}
p.st.selected = selected
// 1. Encode + encrypt the vote under the election public key.
product := returncodes.EncodeVote(selected, p.st.primes)
voteElem, err := emath.NewGqElement(product, group)
if err != nil {
return nil, fmt.Errorf("vote product not in group: %w", err)
}
ct := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(voteElem)), emath.RandomZqElement(zq), singlePK(p.st.electionPK))
// 2. Verification-card key pair.
p.st.vcSK = emath.RandomZqElement(zq)
vcPK := group.Generator().Exponentiate(p.st.vcSK)
// 3. Exponentiated ciphertext components (E1_tilde).
gammaExp := ct.Gamma.Exponentiate(p.st.vcSK)
phi0Exp := ct.GetPhi(0).Exponentiate(p.st.vcSK)
// 4. Exponentiation proof: knowledge of vcSK s.t. (vcPK,gammaExp,phi0Exp) are
// (g,gamma,phi0) raised to the same vcSK. This binds the ballot to vcPK.
bases := emath.GqVectorOf(group.Generator(), ct.Gamma, ct.GetPhi(0))
exps := emath.GqVectorOf(vcPK, gammaExp, phi0Exp)
expProof := zkp.GenExponentiationProof(bases, p.st.vcSK, exps, group,
hash.HashableString{Value: cfg.ElectionID},
hash.HashableString{Value: p.st.card.VerificationCardID},
)
ballot := wireBallot{
VoterID: p.st.card.VoterID,
VcID: p.st.card.VerificationCardID,
Ciphertext: encodeCiphertext(ct),
ExponentiatedG: gammaExp.Value().String(),
ExponentiatedP: phi0Exp.Value().String(),
VcPK: vcPK.Value().String(),
ExpProof: encodeExponentiation(expProof),
}
return p.cer.send(p.id, NameServer, MsgCastBallot, ballot)
}
// singlePK returns a single-element public key (index 0) for encrypting the vote
// product as a one-component message.
func singlePK(pk elgamal.PublicKey) elgamal.PublicKey {
return elgamal.PublicKey{Elements: emath.GqVectorOf(pk.Elements.Get(0))}
}
// handleCastBallot (server) validates a ballot's structure, asks every CC to
// verify its exponentiation proof, and stores it on unanimous acceptance.
func (p *VotingServer) handleCastBallot(env *transport.Envelope) (*transport.Envelope, error) {
var b wireBallot
if err := transport.UnmarshalPayload(env.Payload, &b); err != nil {
return nil, err
}
group := p.cer.Config.Group
// Decode + validate all group elements (rejects malformed ballots cleanly).
ct, err := b.Ciphertext.decode(group)
if err != nil {
return nil, fmt.Errorf("ballot ciphertext: %w", err)
}
vcPK, err := strToGq(b.VcPK, group)
if err != nil {
return nil, fmt.Errorf("ballot vcPK: %w", err)
}
if _, err := strToGq(b.ExponentiatedG, group); err != nil {
return nil, fmt.Errorf("ballot exp gamma: %w", err)
}
if _, err := strToGq(b.ExponentiatedP, group); err != nil {
return nil, fmt.Errorf("ballot exp phi0: %w", err)
}
// Route to every CC for proof verification; require unanimous acceptance.
for j := 0; j < p.cer.Config.NumCCs; j++ {
env2, err := p.cer.send(p.id, CCName(j), MsgVerifyBallot, b)
if err != nil {
return nil, fmt.Errorf("server->cc%d verify: %w", j, err)
}
var verdict ballotVerdict
if err := transport.UnmarshalPayload(env2.Payload, &verdict); err != nil {
return nil, err
}
if !verdict.Accept {
return nil, fmt.Errorf("cc%d rejected ballot %s: %s", j, b.VcID, verdict.Reason)
}
}
// Store the ballot (persisting vcPK, finding F6).
p.st.ballotBox = append(p.st.ballotBox, encryptedBallot{
VoterID: b.VoterID,
VerificationCardID: b.VcID,
Ciphertext: ct,
VcPK: vcPK,
})
return reply(p.id, env.From, MsgBallotStored, env.Nonce, ackPayload{Party: p.id.Name, OK: true})
}
type ballotVerdict struct {
Accept bool `json:"accept"`
Reason string `json:"reason"`
}
// handleVerifyBallot (CC) re-derives the exponentiation-proof statement and
// verifies it. A malformed proof yields a clean reject, never a panic.
func (p *ControlComponent) handleVerifyBallot(env *transport.Envelope) (*transport.Envelope, error) {
var b wireBallot
if err := transport.UnmarshalPayload(env.Payload, &b); err != nil {
return nil, err
}
cfg := p.cer.Config
group := cfg.Group
zq := emath.ZqGroupFromGqGroup(group)
reject := func(reason string) (*transport.Envelope, error) {
return reply(p.id, env.From, MsgBallotVerdict, env.Nonce, ballotVerdict{Accept: false, Reason: reason})
}
ct, err := b.Ciphertext.decode(group)
if err != nil {
return reject("bad ciphertext")
}
vcPK, err := strToGq(b.VcPK, group)
if err != nil {
return reject("bad vcPK")
}
gammaExp, err := strToGq(b.ExponentiatedG, group)
if err != nil {
return reject("bad exp gamma")
}
phi0Exp, err := strToGq(b.ExponentiatedP, group)
if err != nil {
return reject("bad exp phi0")
}
proof, err := b.ExpProof.decodeExponentiation(zq)
if err != nil {
return reject("bad proof encoding")
}
bases := emath.GqVectorOf(group.Generator(), ct.Gamma, ct.GetPhi(0))
exps := emath.GqVectorOf(vcPK, gammaExp, phi0Exp)
ok := zkp.VerifyExponentiationProof(bases, exps, proof, group,
hash.HashableString{Value: cfg.ElectionID},
hash.HashableString{Value: b.VcID},
)
if !ok {
return reject("exponentiation proof INVALID")
}
return reply(p.id, env.From, MsgBallotVerdict, env.Nonce, ballotVerdict{Accept: true})
}