Technical Overview
How ZeroVote Works
ZeroVote uses cryptography to guarantee fair elections. No one sees tallies before anyone else. No one needs to count votes. And no one can corrupt the outcome. This is how we do it.
Timelock Encryption
We encrypt every ballot to a specific future round of the drand distributed randomness beacon (Quicknet chain). drand is a network of independent organizations that produce publicly verifiable random values every 3 seconds using threshold BLS signatures on BLS12-381.
The randomness for round N doesn't exist until round N arrives. No single party holds the decryption key. It's a collective signature that requires a threshold of the network. Until the scheduled reveal time, decryption is physically impossible — not just administratively prevented.
When the round arrives, the beacon publishes its signature. That signature is the decryption key for every ballot encrypted to that round. Everyone gets it at the same moment.
// Encryption (at vote time) ciphertext = tlock_encrypt(ballot, drand_public_key, round_number) // Decryption (when drand round arrives) beacon = fetch_drand_beacon(round_number) verify_bls_signature(beacon.signature, round_number) // anyone can verify ballot = tlock_decrypt(ciphertext, beacon.signature)
Zero-Knowledge Proofs
Every ballot includes a zero-knowledge proof that it's well-formed, without revealing its content. We use Bulletproofs R1CS with Pedersen commitments on Curve25519.
For single-choice (FPTP) votes, the proof shows the encrypted ballot contains exactly one valid candidate index in [0, N). For ranked-choice (IRV), it also proves all ranks are unique and the ranking length is within bounds. None of this reveals which candidates were chosen.
The server verifies every proof before accepting a ballot. Invalid ballots are rejected immediately. The Pedersen commitment blinding factors never leave the voter's browser — the server only gets the commitments and the R1CS proof. So the server can't open commitments to peek at votes before reveal, even by brute-forcing the small candidate space.
After the reveal, voters can verify the decrypted plaintext matches their original commitment using their locally-stored blinding factors. That completes the chain from ZKP to ciphertext to plaintext.
// At vote time (in the voter's browser via WASM) full_proof = bulletproofs_r1cs_prove( plaintext, // the actual vote (witness) ciphertext, // the encrypted ballot (public) pedersen_commitment, // binding commitment to plaintext blinding_factors, // kept client-side only constraints // "choice < num_candidates", etc. ) // Only the server-safe proof is sent (no blinding factors) server_proof = full_proof.without_blinding_factors() submit(ciphertext, server_proof) // Server-side verification (no access to plaintext or blinding factors) assert bulletproofs_verify(server_proof, ciphertext, commitment, constraints)
Append-Only Bulletin Board
Every accepted ballot goes into a BLAKE3 Merkle tree with domain-separated leaf hashing. The leaf hash uses length-prefixed fields (ballot ID, encrypted vote, ZKP, submission timestamp) to prevent second-preimage attacks across field boundaries.
After each submission, the voter gets a Merkle inclusion proof and the current root hash as a receipt. They can later verify their ballot was included in the final tally by checking the proof against the published root.
The tree is publicly auditable. Anyone can fetch the bulletin board, recompute the root, and check it matches. No ballot can be added, removed, or modified without changing the root.
// Domain-separated leaf hash leaf = BLAKE3( len(ballot_id) || ballot_id || len(encrypted_vote) || encrypted_vote || len(zkp) || zkp || len(submitted_at) || submitted_at ) // Merkle inclusion proof root = recompute_root(leaf, sibling_hashes, path_indices) assert root == published_root
Blind RSA Signatures
In secret ballot mode, we use RSA blind signatures (RFC 9474, RSABSSA-SHA384-PSS-Randomized) to break the link between a voter's identity and their ballot. Three steps:
- Blinding:The voter's browser generates a random credential, blinds it with the event's RSA public key, and sends the blinded version to the server.
- Signing: The server checks the voter is eligible via their invite token, signs the blinded credential, and marks the token as used. It never sees the actual credential.
- Unblinding:The browser unblinds the signature to get a valid RSA signature on the original credential. The voter submits their ballot with this credential. No invite token, no slot number. The server can verify the signature but can't correlate it to any voter.
Double-vote prevention is a unique constraint on the credential hash. Submit the same credential twice and the second attempt is rejected.
In tracked participation mode, we skip blind signatures. The organizer can see who voted (useful for reminders), and votes are attributable after the reveal — useful for legislative votes, shareholder governance, or anything where accountability matters. Vote content is still encrypted until reveal regardless of mode.
drand Beacon Verification
The reveal service fetches the beacon from drand with failover across four independent API endpoints. Before using any beacon to decrypt ballots, we verify the BLS signature using pairing-based cryptography on BLS12-381:
// Quicknet: unchained, G1 signatures, G2 public key message = SHA-256(round_number_be_bytes) H = hash_to_G1(message, DST="BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_") // Pairing check: e(-sigma, g2) * e(H, pk) == 1 assert multi_miller_loop([-sigma, H], [g2, public_key]).final_exp() == 1
The drand public key is hardcoded from the Quicknet chain info. We publish the beacon signature alongside results so anyone can verify it independently, including by checking directly against the public drand API.
Deterministic Tallying
Once the drand beacon arrives, we decrypt and tally every ballot in a single deterministic pass. No human counts votes.
- Fetch the drand beacon and verify its BLS signature
- Decrypt every ballot using the beacon signature as the tlock key
- Tally using the event's voting method (FPTP or IRV)
- Publish the aggregate results and the drand signature for verification
Pedersen commitment verification happens client-side. Voters use their locally-stored blinding factors to confirm the decrypted results match the commitments in their original proofs.
FPTP is a simple count. For IRV (instant-runoff), we run elimination rounds: each round eliminates the candidate with the fewest first-preference votes and redistributes their ballots to the next-ranked candidate. This continues until someone has a majority.
We only publish aggregate tallies, never individual decrypted ballots. In secret ballot mode, there's no mapping from ballots to voters at any stage.
Independent Verification
The bulletin board and drand beacon are both public. Prediction markets, auditors, journalists — anyone can verify an election result with zero dependency on us.
The open-source zerovote-verifier crate is both a library and a standalone CLI. It fetches a snapshot of the election, retrieves the drand beacon, verifies every ZKP, rebuilds the Merkle tree, decrypts all ballots, and recomputes the tally.
Prediction markets can settle trades at the exact moment drand reveals the decryption key. No API polling, no waiting for our server. The verifier runs the same cryptographic pipeline we do, using the same public inputs.
$ zerovote-verify --slug board-election-2025 --api https://zerovote.app
{
"event_name": "Board Election 2025",
"beacon_verified": true,
"zkp_passed": 47,
"zkp_failed": 0,
"merkle_root_match": true,
"tally": {
"winner": 2,
"is_tie": false,
"totals": [12, 15, 20],
"total_ballots": 47
}
}Security Properties
Implementation
Backend
Rust + Axum
Database
PostgreSQL + SQLx
Timelock
tlock on drand Quicknet
ZKPs
Bulletproofs R1CS (Curve25519)
Blind Signatures
RSA-PSS (RFC 9474)
Merkle Tree
BLAKE3 with domain separation
Client Crypto
Rust compiled to WASM
Frontend
Next.js + TypeScript
Verifier
Standalone CLI + library crate
Ready to run a trustless election?
Create an event, invite voters, and let the math handle the rest.
Get Started