Building a Cryptographically Verifiable Multi-Candidate Election System

In our previous post, we built a privacy-preserving, verifiable Yes/No referendum using partially homomorphic encryption and zero-knowledge proofs. But real-world elections are rarely binary: they have multiple candidates, parties, or choices. That raises a new question: how do we tally votes for many options under encryption, without one candidate’s count bleeding into another’s, while still proving that every ballot is valid?

Red, white, and blue political buttons with ‘Vote’ and ‘I Voted’ text for election and civic engagement themes by pexels

LightPHE offers two strategies, and the cryptosystem you choose decides which ones are available:


🙋‍♂️ You may consider to enroll my top-rated cryptography course on Udemy

Cryptography Basiscs From Scratch In Python

  • Base encoding packs every candidate’s tally into a single ciphertext by encrypting a vote for candidate k as base^k. The decrypted tally is a large number, so this needs a cryptosystem with a large plaintext space and direct decryption. So, Paillier or Damgard-Jurik are strong candidates for this approach.
  • One-hot encodes each ballot as an array of 0/1 ciphertexts, one per candidate, with a proof that exactly one slot is 1. Each candidate’s count stays small, so it works with Exponential ElGamal and Elliptic Curve ElGamal as well as Paillier and Damgard-Jurik.

Both approaches rely on the same building blocks: homomorphic addition to tally ballots without decrypting them, zero-knowledge proofs that every ballot is valid and bound to its voter, and threshold decryption in which trustees prove their partial results are correct, so no single party can read individual votes or fake the outcome.

In this post, we’ll dive into both approaches, compare their trade-offs, and walk through complete implementations in Python, Go, Java, and TypeScript.

Designing a Multi-Candidate E-Voting System

Our system will use four building blocks, all of which apply to both approaches:

1. Homomorphic addition. Ballots are summed while still encrypted, so individual votes are never decrypted.

2. Zero-knowledge proofs of ballot validity. These are OR-proofs, meaning they show a ballot encrypts one of the allowed values without revealing which:

  • Base encoding: a proof that the ciphertext encrypts one of base^0, base^1, …, base^(k−1).
  • One-hot: a proof that each slot is 0 or 1, plus a proof that the slots add up to 1.

3. Binding each proof to its voter. Each proof is tied to the voter’s identity (context=voter). That’s why Mallory’s copied ballot and Oscar’s doubled or reordered ballots fail to verify.

4. Verifiable decryption. Either a single authority decrypts with a proof (decrypt_with_proof), or trustees run threshold decryption where each partial decryption carries a proof. The cheating-trustee testshows that without these proofs, a trustee can quietly add 50 votes.

Approach 1: Scalar Base Encoding (Single Ciphertext)

In this approach, we utilize additive cryptosystems like Paillier or Damgard-Jurik. Instead of creating multiple ciphertexts for each candidate, we pack all party votes into a single ciphertext using a chosen base (B).

For instance, if our base is 100, a vote for a party k is encrypted as 100k.

Red (Index 0): 1000 = 1

Green (Index 1): 1001 = 100

Blue (Index 2): 1002 = 10000

During tallying, all individual ballots are homomorphically added together into one single ciphertext. When the authority decrypts the final tally, the result is a packed integer (e.g., 10002), which can be easily split by reading digits from right to left.





While Exponential-ElGamal or EC-ElGamal could mathematically support this, their decryption requires solving a massive Discrete Logarithm Problem (DLP), making them entirely impractical for Base encoding. Therefore, this approach is strictly reserved for additive schemes like Paillier and Damgard-Jurik

Python

from lightphe import LightPHE

# authority builds the cryptosystem and publishes its public key
authority_cs = LightPHE(algorithm_name = "Paillier")
authority_cs.export_keys(target_file = "public.txt", public = True)
public_cs = LightPHE(algorithm_name = "Paillier", key_file = "public.txt")

# a vote for party k is encrypted as 100^k, i.e. 1 for red, 100 for green and 10000 for blue
parties = ["red", "green", "blue"]
values = [100 ** k for k in range(len(parties))]

# each voter encrypts a vote for one party with a proof
alice_ballot, alice_proof = public_cs.encrypt_with_proof(values[0], values = values, context = "alice")
bob_ballot, bob_proof = public_cs.encrypt_with_proof(values[2], values = values, context = "bob")
carol_ballot, carol_proof = public_cs.encrypt_with_proof(values[0], values = values, context = "carol")

# anyone verifies ballots without decrypting them
assert public_cs.verify_ciphertext(alice_ballot, alice_proof, values = values, context = "alice") is True
assert public_cs.verify_ciphertext(bob_ballot, bob_proof, values = values, context = "bob") is True
assert public_cs.verify_ciphertext(carol_ballot, carol_proof, values = values, context = "carol") is True

# valid ballots are tallied homomorphically, and the authority decrypts the tally with a proof
tally = alice_ballot + bob_ballot + carol_ballot
result, proof = authority_cs.decrypt_with_proof(tally)
assert public_cs.verify_decryption(tally, result, proof) is True

# tally is 2 x 1 + 0 x 100 + 1 x 10000 = 10002, read 2 digits at a time from right: 1 | 00 | 02
votes = {party: (result // 100 ** k) % 100 for k, party in enumerate(parties)}
assert votes == {"red": 2, "green": 0, "blue": 1}

Go

// authority builds the cryptosystem and publishes its public key
authorityCs, err := lightphe.New(lightphe.Paillier)
if err != nil {
    log.Fatalf("failed to create authority: %v", err)
}
if err := authorityCs.ExportKeys("public.txt", true); err != nil {
    log.Fatalf("failed to export keys: %v", err)
}
publicCs, err := lightphe.New(lightphe.Paillier, lightphe.WithKeyFile("public.txt"))
if err != nil {
    log.Fatalf("failed to load public cs: %v", err)
}

parties := []string{"red", "green", "blue"}
values := []*big.Int{big.NewInt(1), big.NewInt(100), big.NewInt(10000)}

aliceBallot, aliceProof, err := publicCs.EncryptWithProof(values[0], values, "alice")
bobBallot, bobProof, err := publicCs.EncryptWithProof(values[2], values, "bob")
carolBallot, carolProof, err := publicCs.EncryptWithProof(values[0], values, "carol")

valid, err := publicCs.VerifyCiphertext(aliceBallot, aliceProof, values, "alice")
valid, err = publicCs.VerifyCiphertext(bobBallot, bobProof, values, "bob")
valid, err = publicCs.VerifyCiphertext(carolBallot, carolProof, values, "carol")

tally, err := aliceBallot.Add(bobBallot)
tally, err = tally.Add(carolBallot)
result, proof, err := authorityCs.DecryptWithProof(tally)
valid, err = publicCs.VerifyDecryption(tally, result, proof)

votes := map[string]int64{}
for _, party := range parties {
    digit := new(big.Int)
    result.DivMod(result, big.NewInt(100), digit)
    votes[party] = digit.Int64()
}

Java

List<String> parties = List.of("red", "green", "blue");
List<Long> values = List.of(1L, 100L, 10000L);

LightPHE authorityCs = LightPHE.builder().algorithmName("Paillier").build();
Proven<Ciphertext> alice = authorityCs.encryptWithProof(values.get(0), values, "alice");
Proven<Ciphertext> bob = authorityCs.encryptWithProof(values.get(2), values, "bob");
Proven<Ciphertext> carol = authorityCs.encryptWithProof(values.get(0), values, "carol");
assert authorityCs.verifyCiphertext(alice.value(), alice.proof(), values, "alice");

Ciphertext tally = alice.value().add(bob.value()).add(carol.value());
BigInteger result = authorityCs.decryptWithProof(tally).value();
assert result.equals(BigInteger.valueOf(10002));

TypeScript

const authorityCs = new LightPHE({ algorithmName: 'Paillier' });
authorityCs.exportKeys('public.txt', true);
const publicCs = new LightPHE({
  algorithmName: 'Paillier',
  keyFile: 'public.txt',
});

const parties = ['red', 'green', 'blue'];
const values = parties.map((_, k) => 100n ** BigInt(k));

const ballots = [
  ['alice', 'red'],
  ['bob', 'blue'],
  ['carol', 'red'],
].map(([voter, party]) => {
  const [ballot, proof] = publicCs.encryptWithProof(
    values[parties.indexOf(party)],
    { values, context: voter }
  );
  console.assert(
    publicCs.verifyCiphertext(ballot, proof, { values, context: voter })
  );
  return ballot;
});

const tally = ballots.reduce((sum, ballot) => sum.add(ballot));
const [result, proof] = authorityCs.decryptWithProof(tally);
console.assert(publicCs.verifyDecryption(tally, result, proof));

const votes = parties.map((_, k) => (result / 100n ** BigInt(k)) % 100n);
console.assert(votes.join() === '2,0,1');

One-Hot Encoding

In this approach, rather than packing all party votes into a single integer, each voter casts a vote as a one-hot vector (e.g., [1, 0, 0, 0] for a vote to the first party).

Supported by cryptosystems like Exponential-ElGamal, EC-ElGamal, Paillier, and Damgard-Jurik, individual ballots consist of an array of ciphertexts along with zero-knowledge proofs. During tallying, votes are summed slot by slot, meaning each party’s votes are counted independently in its own dedicated ciphertext slot.

Python

from lightphe import LightPHE

# authority builds the cryptosystem and publishes its public key
authority_cs = LightPHE(algorithm_name = "Exponential-ElGamal", prime_order = True)
authority_cs.export_keys(target_file = "public.txt", public = True)
public_cs = LightPHE(algorithm_name = "Exponential-ElGamal", key_file = "public.txt")

parties = ["red", "green", "blue", "yellow"]

# each voter encrypts a vote for one party as a one-hot ballot with a proof
alice_ballot, alice_proof = public_cs.encrypt_one_hot(parties.index("red"), options = len(parties), context = "alice")
bob_ballot, bob_proof = public_cs.encrypt_one_hot(parties.index("blue"), options = len(parties), context = "bob")
carol_ballot, carol_proof = public_cs.encrypt_one_hot(parties.index("red"), options = len(parties), context = "carol")

# anyone verifies ballots without decrypting them
assert public_cs.verify_one_hot(alice_ballot, alice_proof, context = "alice") is True
assert public_cs.verify_one_hot(bob_ballot, bob_proof, context = "bob") is True
assert public_cs.verify_one_hot(carol_ballot, carol_proof, context = "carol") is True

# valid ballots are summed slot by slot, so each party's votes are counted in its own ciphertext
tally = [a + b + c for a, b, c in zip(alice_ballot, bob_ballot, carol_ballot)]

# authority decrypts the count of each party with a proof
votes = {}
for party, count in zip(parties, tally):
    result, proof = authority_cs.decrypt_with_proof(count)
    assert public_cs.verify_decryption(count, result, proof) is True
    votes[party] = result

assert votes == {"red": 2, "green": 0, "blue": 1, "yellow": 0}

Go

authorityCs, err := lightphe.New(lightphe.ExponentialElGamal, lightphe.WithPrimeOrder(true))
if err != nil {
    log.Fatalf("failed to create authority: %v", err)
}
if err := authorityCs.ExportKeys("public.txt", true); err != nil {
    log.Fatalf("failed to export keys: %v", err)
}
publicCs, err := lightphe.New(lightphe.ExponentialElGamal, lightphe.WithKeyFile("public.txt"))
if err != nil {
    log.Fatalf("failed to load public cs: %v", err)
}

parties := []string{"red", "green", "blue", "yellow"}

aliceBallot, aliceProof, err := publicCs.EncryptOneHot(0, len(parties), "alice")
bobBallot, bobProof, err := publicCs.EncryptOneHot(2, len(parties), "bob")
carolBallot, carolProof, err := publicCs.EncryptOneHot(0, len(parties), "carol")

valid, err := publicCs.VerifyOneHot(aliceBallot, aliceProof, "alice")
valid, err = publicCs.VerifyOneHot(bobBallot, bobProof, "bob")
valid, err = publicCs.VerifyOneHot(carolBallot, carolProof, "carol")

votes := map[string]int64{}
for k, party := range parties {
    count, err := aliceBallot[k].Add(bobBallot[k])
    count, err = count.Add(carolBallot[k])

    result, proof, err := authorityCs.DecryptWithProof(count)
    valid, err = publicCs.VerifyDecryption(count, result, proof)
    votes[party] = result.Int64()
}

Java

LightPHE authorityCs =
        LightPHE.builder().algorithmName("Exponential-ElGamal").primeOrder(true).build();
List<String> parties = List.of("red", "green", "blue", "yellow");

Proven<List<Ciphertext>> alice = authorityCs.encryptOneHot(parties.indexOf("red"), 4, "alice");
Proven<List<Ciphertext>> bob = authorityCs.encryptOneHot(parties.indexOf("blue"), 4, "bob");

assert authorityCs.verifyOneHot(alice.value(), alice.proof(), "alice");
assert authorityCs.verifyOneHot(bob.value(), bob.proof(), "bob");

List<BigInteger> votes = new ArrayList<>();
for (int k = 0; k < parties.size(); k++) {
    Ciphertext count = alice.value().get(k).add(bob.value().get(k));
    votes.add(authorityCs.decryptWithProof(count).value());
}
assert votes.equals(List.of(BigInteger.ONE, BigInteger.ZERO, BigInteger.ONE, BigInteger.ZERO));

TypeScript

const authorityCs = new LightPHE({
  algorithmName: 'Exponential-ElGamal',
  primeOrder: true,
});
authorityCs.exportKeys('public.txt', true);
const publicCs = new LightPHE({
  algorithmName: 'Exponential-ElGamal',
  keyFile: 'public.txt',
});

const parties = ['red', 'green', 'blue', 'yellow'];

const ballots = [
  ['alice', 'red'],
  ['bob', 'blue'],
  ['carol', 'red'],
].map(([voter, party]) => {
  const [ballot, proof] = publicCs.encryptOneHot(
    parties.indexOf(party),
    parties.length,
    { context: voter }
  );
  console.assert(publicCs.verifyOneHot(ballot, proof, { context: voter }));
  return ballot;
});

const tally = ballots.reduce((sums, ballot) =>
  sums.map((sum, i) => sum.add(ballot[i]))
);

const votes = tally.map((count) => {
  const [result, proof] = authorityCs.decryptWithProof(count);
  console.assert(publicCs.verifyDecryption(count, result, proof));
  return result;
});
console.assert(votes.join() === '2,0,1,0');

Architectural Comparison

When choosing between Base Encoding and One-Hot Encoding for a multi-candidate election system, developers must weigh key trade-offs regarding algorithm compatibility, bandwidth, decryptions, and capacity limits:

One-hotBase Encoding
AlgorithmsEC ElGamal, Exponential ElGamal, Paillier, Damgard-JurikPaillier, Damgard-Jurik
Max votes per partyNo limitbase – 1
Max partiesNo limitbase ^ parties < n (e.g. 51 parties at base 10^6 with 1024 bit key)
Ballot sizeGrows with the number of parties (k ciphertexts and k+1 proofs)One ciphertext, with a proof that grows with the number of parties
Tally decryptionsOne per partyOne in total

When to Choose Base Encoding:

  • Ideal for elections with a small, fixed number of candidates and a known voter cap (e.g., board elections or local council voting).
  • Minimizes network payload during ballot submission since the entire ballot fits into a single ciphertext.
  • Drastically speeds up final tallying, requiring only one decryption operation regardless of the candidate count.

When to Choose One-Hot Encoding:

  • Essential for large-scale, nationwide, or open-ended elections where candidate counts or total voters could exceed packed integer capacity.
  • Completely eliminates overflow risk (base – 1) and modulus capacity constraints (base^parties < n).
  • Allows full leverage of Exponential-ElGamal and EC-ElGamal cryptosystems alongside Paillier and Damgard-Jurik.

Conclusion

Moving from a Yes/No referendum to a multi-candidate election doesn’t need new cryptography. It needs a smarter way to encode the ballot. Base encoding packs every candidate’s count into one ciphertext, so each ballot is compact and the whole tally is decrypted once. In exchange, you’re tied to Paillier or Damgard-Jurik and to two ceilings: votes per candidate must stay below the base, and base^candidates must fit within the key’s plaintext space. One-hot puts each candidate in its own slot. That removes both ceilings and opens the door to Exponential ElGamal and EC ElGamal, at the cost of ballots and proofs that grow with every candidate and one decryption per candidate.

Whichever you choose, the guarantees stay the same: ballots are tallied without being decrypted, zero-knowledge proofs reject malformed, duplicated or tampered ballots, and threshold decryption with proofs keeps any single trustee from reading votes or forging the result. As a rule of thumb, base encoding suits elections with a few candidates and a modest number of voters per candidate, while one-hot scales to long ballots and gives you the widest choice of cryptosystems. With LightPHE, either one is only a few lines of code.


Support this blog financially if you do like!

Buy me a coffee      Buy me a coffee


Leave a Reply