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:

  1. 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.
  2. 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.
  3. 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.

  1. Fetch the drand beacon and verify its BLS signature
  2. Decrypt every ballot using the beacon signature as the tlock key
  3. Tally using the event's voting method (FPTP or IRV)
  4. 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

Pre-reveal confidentiality: Timelock encryption means no one sees any vote before the drand round. The decryption key doesn't exist yet. Blinding factors stay client-side, so even the server can't brute-force commitments early.
Ballot integrity: The BLAKE3 Merkle tree with domain-separated leaf hashing provides tamper evidence. Every voter gets an inclusion proof.
Vote validity: Bulletproofs R1CS proofs guarantee every accepted ballot contains a well-formed vote, verified before acceptance.
Secret ballot (optional): RSA blind signatures (RFC 9474) mathematically prevent the server from linking voters to ballots. In tracked participation mode, the organizer can see who voted but not how.
Double-vote prevention: Unique constraints on slot numbers (tracked participation) or credential hashes (secret ballot) prevent duplicate ballots atomically.
Verifiable results: The bulletin board, drand beacon, and tally algorithm are all public. Anyone can run the open-source verifier to decrypt ballots, check every ZKP, rebuild the Merkle tree, and recompute the tally. Zero dependency on us.
No single point of trust: drand is a distributed network. The server can't decrypt votes early. The Merkle tree prevents tampering. ZKPs prevent invalid ballots. Math enforces fairness.

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