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.

The Cryptographic Blueprint
A secure e-voting workflow requires four essential phases:
🙋♂️ You may consider to enroll my top-rated cryptography course on Udemy
- 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.
- 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.
- Homomorphic Tallying: Anyone can aggregate the valid encrypted ballots using the homomorphic property of the cryptosystem—adding ciphertexts together without ever decrypting individual votes.
- 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
- 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. - Decentralized Trust: Threshold decryption guarantees that no single rogue administrator or compromised server can decrypt individual ballots or prematurely access election outcomes.
- 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:
- 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.
- 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!
