Ballot carries E2 + plaintext-equality proof, CC-verified

The voter now also submits E2 = Enc(vote, returnCodesPK[0]) and a plaintext-
equality proof that E2 and the ballot's slot 0 encrypt the SAME vote. Every CC
verifies this proof during ballot verification. This is the soundness link that
makes the return code cast-as-intended: a client that encrypts one vote for the
tally and a different one for the return-code channel is rejected, so the code
the CCs compute from E2 necessarily reflects the tallied vote.

- transcript: publish the combined return-codes public key.
- voter stores returnCodePK from the (confidential) card delivery.
- wire: plaintext-equality proof DTO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-07-06 15:47:53 +02:00
parent 577205a2ff
commit d2cfbf291f
6 changed files with 99 additions and 15 deletions

View file

@ -163,8 +163,13 @@ func (p *VoterClient) handleVotingCard(env *transport.Envelope) (*transport.Enve
if err != nil {
return nil, err
}
rcPK, err := d.ReturnCodePK.decode(group)
if err != nil {
return nil, err
}
p.st.card = &d.Card
p.st.electionPK = pk
p.st.returnCodePK = rcPK
p.st.primes = make([]*big.Int, len(d.Primes))
for i, ps := range d.Primes {
v, ok := new(big.Int).SetString(ps, 10)

View file

@ -129,6 +129,7 @@ func (c *Ceremony) RunSetup() error {
c.Transcript.ElectionID = cfg.ElectionID
c.Transcript.EBPublicKey = ebPK
c.Transcript.ElectionPK = c.Setup.st.electionPK
c.Transcript.ReturnCodePK = c.Setup.st.returnCodePK
c.Transcript.Primes = make([]string, len(c.Setup.st.primes))
for i, p := range c.Setup.st.primes {
c.Transcript.Primes[i] = p.String()

View file

@ -52,11 +52,12 @@ type serverState struct {
// voterState is a voter client's private card and ballot secrets.
type voterState struct {
card *votingCard
electionPK elgamal.PublicKey
primes []*big.Int
vcSK emath.ZqElement
selected []int
card *votingCard
electionPK elgamal.PublicKey
returnCodePK elgamal.PublicKey
primes []*big.Int
vcSK emath.ZqElement
selected []int
}
// votingCard is a voter's credential bundle (private to the voter, produced by

View file

@ -18,6 +18,7 @@ type PublicTranscript struct {
CCSchnorr [][]zkp.SchnorrProof // per-CC Schnorr proofs of key knowledge
EBPublicKey elgamal.PublicKey // electoral board public key
ElectionPK elgamal.PublicKey // combined election public key
ReturnCodePK elgamal.PublicKey // combined return-codes public key (CCs only, no EB)
Primes []string // decimal encodings of the encoding primes
// Tally artifacts.

View file

@ -21,16 +21,20 @@ const (
// 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.
// remote party can reconstruct the proof statement — finding F6), the
// exponentiation proof binding vcPK to the exponentiation, and the return-code
// ciphertext E2 with a plaintext-equality proof binding it to the ballot (so the
// return code is genuinely derived from the submitted vote — cast-as-intended).
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"`
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"`
ReturnCodeCT wireCiphertext `json:"return_code_ct"` // E2: Enc(vote, returnCodesPK[0])
EqProof wirePlaintextEquality `json:"eq_proof"` // proves E1[0] and E2 encrypt the same vote
}
// RunVoting has every voter encrypt its selection and submit a ballot; the
@ -75,7 +79,8 @@ func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
for i := 1; i < cfg.NumOptions; i++ {
msgElems[i] = group.Identity()
}
ct := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(msgElems...)), emath.RandomZqElement(zq), p.st.electionPK)
msgRandomness := emath.RandomZqElement(zq)
ct := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(msgElems...)), msgRandomness, p.st.electionPK)
// 2. Verification-card key pair.
p.st.vcSK = emath.RandomZqElement(zq)
@ -94,6 +99,25 @@ func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
hash.HashableString{Value: p.st.card.VerificationCardID},
)
// 5. Return-code ciphertext E2 and the plaintext-equality proof binding it
// to the ballot. E2 encrypts the same vote value (slot 0) under the
// return-codes key; the proof lets the CCs trust that the return code
// they compute from E2 reflects the actually-submitted vote. (Single
// selected option — the return-code channel is defined for one choice.)
rcPK0 := elgamal.PublicKey{Elements: emath.GqVectorOf(p.st.returnCodePK.Get(0))}
r2 := emath.RandomZqElement(zq)
e2 := elgamal.Encrypt(elgamal.NewMessage(emath.GqVectorOf(voteElem)), r2, rcPK0)
// Single-component view of the ballot's slot 0 for the equality statement.
c1 := elgamal.NewCiphertext(ct.Gamma, emath.GqVectorOf(ct.GetPhi(0)))
eqProof := zkp.GenPlaintextEqualityProof(
c1, e2,
p.st.electionPK.Get(0), p.st.returnCodePK.Get(0),
msgRandomness, r2, 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,
@ -102,6 +126,8 @@ func (p *VoterClient) castBallot(selected []int) (*transport.Envelope, error) {
ExponentiatedP: phi0Exp.Value().String(),
VcPK: vcPK.Value().String(),
ExpProof: encodeExponentiation(expProof),
ReturnCodeCT: encodeCiphertext(e2),
EqProof: encodePlaintextEquality(eqProof),
}
return p.cer.send(p.id, NameServer, MsgCastBallot, ballot)
}
@ -206,5 +232,29 @@ func (p *ControlComponent) handleVerifyBallot(env *transport.Envelope) (*transpo
if !ok {
return reject("exponentiation proof INVALID")
}
// Verify the plaintext-equality proof binding E2 to the ballot's slot 0.
// This is what makes the return code cast-as-intended: if E2 encrypted a
// different vote than the ballot, this proof fails and the ballot is
// rejected, so the code computed from E2 must reflect the tallied vote.
e2, err := b.ReturnCodeCT.decode(group)
if err != nil {
return reject("bad return-code ciphertext")
}
eqProof, err := b.EqProof.decode(zq)
if err != nil {
return reject("bad equality proof encoding")
}
c1 := elgamal.NewCiphertext(ct.Gamma, emath.GqVectorOf(ct.GetPhi(0)))
eqOK := zkp.VerifyPlaintextEqualityProof(
c1, e2,
p.cer.Transcript.ElectionPK.Get(0), p.cer.Transcript.ReturnCodePK.Get(0),
eqProof, group,
hash.HashableString{Value: cfg.ElectionID},
hash.HashableString{Value: b.VcID},
)
if !eqOK {
return reject("plaintext-equality proof INVALID")
}
return reply(p.id, env.From, MsgBallotVerdict, env.Nonce, ballotVerdict{Accept: true})
}

View file

@ -138,6 +138,32 @@ func (w wireSchnorr) decodeExponentiation(zq *emath.ZqGroup) (zkp.Exponentiation
return zkp.ExponentiationProof{E: e, Z: z}, nil
}
// --- plaintext-equality proof (E scalar + Z vector of 2) ---
type wirePlaintextEquality struct {
E string `json:"e"`
Z []string `json:"z"`
}
func encodePlaintextEquality(p zkp.PlaintextEqualityProof) wirePlaintextEquality {
return wirePlaintextEquality{E: zqToStr(p.E), Z: zqVecToStrs(p.Z)}
}
func (w wirePlaintextEquality) decode(zq *emath.ZqGroup) (zkp.PlaintextEqualityProof, error) {
e, err := strToZq(w.E, zq)
if err != nil {
return zkp.PlaintextEqualityProof{}, fmt.Errorf("pe E: %w", err)
}
z, err := strsToZqVec(w.Z, zq)
if err != nil {
return zkp.PlaintextEqualityProof{}, fmt.Errorf("pe Z: %w", err)
}
if z.Size() != 2 {
return zkp.PlaintextEqualityProof{}, fmt.Errorf("pe Z must have size 2, got %d", z.Size())
}
return zkp.PlaintextEqualityProof{E: e, Z: z}, nil
}
// --- ciphertext ---
type wireCiphertext struct {