Building a Cryptographically Verifiable Referendum System

A binary referendum (such as a Leave/Remain or Yes/No vote) presents a fundamental cryptographic paradox: voter privacy requires that nobody can see how an individual voted, while public verifiability requires that everyone can independently verify that the final tally is accurate. How can we achieve both? By combining Partially Homomorphic Encryption (PHE), Zero-Knowledge Proofs (ZKPs), and Threshold Decryption, it’s very straightforward. In this post, we’ll explore how to build a secure, verifiable referendum system using LightPHE—a multi-language library that supports robust PHE schemes including EC ElGamal, Exponential ElGamal, Paillier, and Damgard-Jurik with Python, Golang, Java and TypeScript languages.

European Union flag with missing stars representing Brexit concept by pexels

The Cryptographic Blueprint

A secure e-voting workflow requires four essential phases:


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

Cryptography Basiscs From Scratch In Python

  1. Setup & Threshold Key Generation: A central authority generates a public key for encryption, but divides the private key among multiple independent trustees using a threshold scheme (e.g., requiring at least 2 out of 3 trustees to cooperate to decrypt the final result). This prevents a single point of failure or corruption.
  2. Encrypted Ballots with ZKPs: Voters cast binary votes ($0$ or $1$). Along with the ciphertext, each voter attaches a Zero-Knowledge Proof bound to their unique voter context (ID). This ensures:
    • Validity: Ballots must strictly be $0$ or $1$ (preventing malformed votes).
    • Replay Protection: Even if an adversary (like Mallory) tries to copy Alice’s valid ballot and submit it as their own, the context-bound proof will fail verification.
  3. Homomorphic Tallying: Anyone can aggregate the valid encrypted ballots using the homomorphic property of the cryptosystem—adding ciphertexts together without ever decrypting individual votes.
  4. Threshold Decryption: The trustees use their partial keys to collaboratively decrypt the final tally, proving correctness along the way.

Multi-Language Implementation

LightPHE provides identical security primitives across Python, Go, Java, and TypeScript, making it ideal for cross-platform deployments. Below is how you can implement our referendum voting pipeline in all four languages.

Python

With LightPHE, Python provides a concise, high-level interface utilizing pytest-style workflow semantics:

import pytest
from lightphe import LightPHE
import logging

logger = logging.getLogger(__name__)

ALL_ALGORITHMS = [
    ("Exponential-ElGamal", 128, {"prime_order": True}),
    ("EllipticCurve-ElGamal", None, {}),
    ("Paillier", 128, {"safe_primes": True}),
    ("Damgard-Jurik", 128, {"safe_primes": True}),
]

def test_encrypted_voting(algorithm_name, key_size, kwargs):
    """
    Voters cast 0/1 ballots with proofs, invalid ballots are rejected, valid ones are
    tallied homomorphically, and trustees threshold-decrypt the tally with proofs
    """
    authority_cs = LightPHE(algorithm_name=algorithm_name, key_size=key_size, **kwargs)
    share_keys = authority_cs.generate_threshold_keys(threshold=2, parties=3)
    trustees = [LightPHE(algorithm_name=algorithm_name, keys=keys) for keys in share_keys]

    votes = {"alice": 1, "bob": 0, "carol": 1, "dave": 1}
    ballots = {
        voter: authority_cs.encrypt_with_proof(vote, context=voter) for voter, vote in votes.items()
    }

    # Mallory tries a replay attack by copying Alice's ballot
    ballots["mallory"] = ballots["alice"]

    valid = [
        ballot
        for voter, (ballot, proof) in ballots.items()
        if authority_cs.verify_ciphertext(ballot, proof, context=voter)
    ]
    assert len(valid) == len(votes) # Mallory's copied ballot is successfully rejected!

    tally = valid[0]
    for ballot in valid[1:]:
        tally = tally + ballot

    partials = [trustee.partial_decrypt(tally, prove=True) for trustee in trustees[:2]]
    yes_votes = authority_cs.combine_partial_decryptions(tally, partials, verify=True)

    assert yes_votes == 3

    no_votes = len(valid) - yes_votes
    assert no_votes == 1

    assert yes_votes > no_votes, "referendum is expected to pass"

    logger.info(f"✅ Encrypted voting test succeeded for {algorithm_name}")

Go

With lightphe-go, For high-performance systems backend services, the Go implementation offers native concurrency safety and strong typing:

func TestEncryptedVoting(t *testing.T) {
    // voters cast 0/1 ballots with proofs, valid ones are tallied
    // homomorphically, and trustees threshold-decrypt the tally with proofs
    authority := mustBuild(t, phe.ExponentialElGamal, lightphe.WithKeySize(128), lightphe.WithPrimeOrder(true))
    publicKey, shareKeys, err := authority.GenerateThresholdKeys(2, 3)
    if err != nil {
        t.Fatalf("generating threshold keys: %v", err)
    }
    public := mustBuild(t, phe.ExponentialElGamal, lightphe.WithKeys(publicKey))

    votes := map[string]int64{"alice": 1, "bob": 0, "carol": 1, "dave": 1}
    type ballot struct {
        c     *encrypted.Ciphertext
        proof *lightphe.ZKProof
    }
    ballots := map[string]ballot{}
    for voter, vote := range votes {
        c, proof, err := public.EncryptWithProof(big.NewInt(vote), nil, voter)
        if err != nil {
            t.Fatalf("encrypting: %v", err)
        }
        ballots[voter] = ballot{c, proof}
    }

    // Mallory copies alice's ballot
    ballots["mallory"] = ballots["alice"]

    var tally *encrypted.Ciphertext
    for voter, b := range ballots {
        valid, err := public.VerifyCiphertext(b.c, b.proof, nil, voter)
        if err != nil {
            t.Fatalf("verifying: %v", err)
        }
        if _, honest := votes[voter]; valid != honest {
            t.Fatalf("ballot of %s is valid = %v", voter, valid)
        }
        if !valid {
            continue
        }
        if tally == nil {
            tally = b.c
        } else {
            tally = cfAdd(t, tally, b.c)
        }
    }

    var partials []*lightphe.PartialDecryption
    for _, keys := range shareKeys[:2] {
        partial, err := mustBuild(t, phe.ExponentialElGamal, lightphe.WithKeys(keys)).PartialDecrypt(tally, true)
        if err != nil {
            t.Fatalf("partial decryption: %v", err)
        }
        partials = append(partials, partial)
    }
    if m := mustCombine(t, public, tally, partials, true); m.Int64() != 3 {
        t.Fatalf("tally is %s, want 3", m)
    }
}

Java

With lightphe4j, Enterprise-grade architectures can rely on the Java package, pairing seamlessly with JUnit assertions:

@Test
    @DisplayName("Voters cast proven 0/1 ballots, and trustees decrypt the tally with proofs")
    void testEncryptedVoting() {
        ThresholdTest.Committee committee = ThresholdTest.build("Exponential-ElGamal", 128, 2, 3);
        LightPHE authority = committee.dealer();

        Map<String, Long> votes = new LinkedHashMap<>();
        votes.put("alice", 1L);
        votes.put("bob", 0L);
        votes.put("carol", 1L);
        votes.put("dave", 1L);
        Map<String, Proven<Ciphertext>> ballots = new LinkedHashMap<>();
        votes.forEach(
                (voter, vote) -> ballots.put(voter, authority.encryptWithProof(vote, null, voter)));

        // Mallory copies alice's ballot
        ballots.put("mallory", ballots.get("alice"));

        List<Ciphertext> valid = new ArrayList<>();
        ballots.forEach(
                (voter, ballot) -> {
                    if (authority.verifyCiphertext(ballot.value(), ballot.proof(), null, voter)) {
                        valid.add(ballot.value());
                    }
                });
        assertThat(valid).hasSize(votes.size()); // Mallory's duplicate is ignored

        Ciphertext tally = sum(valid, Ciphertext::add);
        List<PartialDecryption> partials =
                committee.shareholders().subList(0, 2).stream()
                        .map(trustee -> trustee.partialDecrypt(tally, true))
                        .toList();
        assertThat(authority.combinePartialDecryptions(tally, partials, true))
                .isEqualTo(BigInteger.valueOf(3));
    }

TypeScript

With lightphe-ts, For web applications, Node.js services, or frontend-adjacent workflows, TypeScript gives you clean, type-safe semantics:

it('runs an encrypted referendum', () => {
    // voters cast 0/1 ballots with proofs, invalid ballots are rejected, valid ones
    // are tallied homomorphically, and trustees threshold-decrypt the tally with proofs
    const authorityCs = new LightPHE({
      algorithmName: 'Exponential-ElGamal',
      keySize: 128,
      primeOrder: true,
    });
    const shareKeys = authorityCs.generateThresholdKeys(2, 3);
    const trustees = shareKeys.map(
      (keys) => new LightPHE({ algorithmName: 'Exponential-ElGamal', keys })
    );

    const votes: Record<string, number> = {
      alice: 1,
      bob: 0,
      carol: 1,
      dave: 1,
    };
    const ballots: Record<string, [Ciphertext, MembershipProof]> = {};
    for (const [voter, vote] of Object.entries(votes)) {
      ballots[voter] = authorityCs.encryptWithProof(vote, { context: voter });
    }

    // Mallory copies alice's ballot
    ballots.mallory = ballots.alice;

    const valid = Object.entries(ballots)
      .filter(([voter, [ballot, proof]]) =>
        authorityCs.verifyCiphertext(ballot, proof, { context: voter })
      )
      .map(([, [ballot]]) => ballot);
    expect(valid.length).toBe(Object.keys(votes).length);

    const tally = valid
      .slice(1)
      .reduce((sum, ballot) => sum.add(ballot), valid[0]);

    const partials = trustees
      .slice(0, 2)
      .map((trustee) => trustee.partialDecrypt(tally, { prove: true }));
    expect(
      authorityCs.combinePartialDecryptions(tally, partials, { verify: true })
    ).toBe(3n);

    logger.info('✅ Encrypted voting test succeeded');
  });

Why This Architecture Matters

  1. Robust Security Against Replay Attacks: When Mallory attempts to clone Alice’s encrypted ballot, the context check (context: voter) embedded in the Zero-Knowledge Proof ensures the cryptographic signature fails verification for anyone except Alice.
  2. Decentralized Trust: Threshold decryption guarantees that no single rogue administrator or compromised server can decrypt individual ballots or prematurely access election outcomes.
  3. Cross-Language Parity: Whether your core infrastructure runs on Python data scripts, Go microservices, Java enterprise apps, or TypeScript web services, LightPHE guarantees identical underlying math and interoperable primitives.

Conclusion & Key Takeaways

By combining Partially Homomorphic Encryption, Zero-Knowledge Proofs, and Threshold Decryption, we successfully resolved the core voting paradox: achieving uncompromised voter privacy alongside end-to-end public verifiability.

Choosing the Right PHE Cryptosystem

LightPHE supports multiple PHE schemes, allowing you to tailor your voting infrastructure based on performance and payload requirements:

  • Exponential ElGamal: Ideal for small-range tallies (like binary referendums) with low bandwidth overhead and efficient ZKP generation.
  • EC ElGamal: Best for resource-constrained client environments (e.g., mobile apps) requiring small key sizes and fast performance.
  • Paillier & Damgard-Jurik: Superior choice when working with large scalar arithmetic or multi-candidate additive aggregation without discrete logarithm limitations during decryption.

Other additive algorithms in LightPHE—like Okamoto-Uchiyama, Benaloh, and Naccache-Stern—can also sum encrypted votes. However, they lack two critical features for a secure election:

  1. No Ballot Proofs: LightPHE cannot generate 0/1 membership proofs for these algorithms. This means a dishonest voter could encrypt a value like 5 instead of 1, effectively casting five votes undetected.
  2. No Threshold Decryption: These schemes do not support threshold keys, meaning a single private key holder could decrypt any individual ballot and destroy voter privacy.

Without ballot proofs and threshold decryption, you cannot run a secure election.

What’s Next?

A binary referendum is the simplest form of homomorphic aggregation (0 vs 1). However, modern real-world elections often require candidates, preferences, or multi-party slates. In our next post, we will expand this architecture from binary referendums to Multi-Party & Ranked-Choice Elections—demonstrating how to structure multi-candidate ballot vectors and scale homomorphic tallying with LightPHE!


Support this blog financially if you do like!

Buy me a coffee      Buy me a coffee


Leave a Reply