Trail of Bits’ E-Voting Primer Turns Verifiability Into Four Settings, and the Public Records Show Helios Hashing Only Proof Commitments, 8 Audits Posted for 1,490 IACR Ballots, and an ElectionGuard Pilot Run Without Chaining
We checked Trail of Bits’ electronic voting primer against the Helios code, the IACR’s public election pages and the ElectionGuard 2.1 specification and pilots, then rebuilt four mechanisms in a toy...
On November 3, U.S. midterm election day, Trail of Bits published “The cryptography behind electronic voting (and why paper ballots are still essential),” a primer by Fredrik Dahlgren on how the Helios and ElectionGuard systems use cryptography to keep ballots secret and make a tally checkable. Its conclusion is cautious: “Neither protocol makes a general claim to solve the problem of electronic voting because neither achieves the security of status quo paper-based systems purely electronically.”
Table Of Content
- What the primer says, and what we checked
- The lab
- Exponential ElGamal, tallies and trustee keys
- Setting 1: how many trustees must show up
- What the IACR record shows
- The other side of the trade
- Measured in the lab
- Setting 2: what the proof hash covers
- What a ballot proof is for
- What the two systems hash
- Measured in the toy group
- What this does and does not show
- Setting 3: how many voters challenge
- How the challenge is meant to work
- What the public Helios pages show
- What a low rate does to detection
- Setting 4: whether anyone checks the chain
- Measured in the toy chain
- What the three ElectionGuard pilots covered
- What the primer gets right
- Five questions to ask about any electronic voting deployment
- Checks on the lab itself
- What we did not test
- Sources
The primer is careful, and most of what it says holds up. What one blog post cannot do is show how the two named systems have actually set the dials its guarantees depend on. Four of them matter: how many trustees must show up to decrypt, what the zero-knowledge proof hash covers, how many voters challenge their ballots, and whether the ballot hash chain is used. We read the primer against the Helios source code, the public pages of the two Helios elections the International Association for Cryptologic Research ran for its 2025 vote, the ElectionGuard 2.1 specification and the three ElectionGuard pilot records, then rebuilt each mechanism in a small lab. Everything was read on November 4, 2026. We tested no live election and built no attack on any deployed system.
What we found:
- The threshold footnote holds, and both sides of the trade are on the record. The IACR’s voided 2025 election needed all three trustees and lost one key, and the IACR says it will move to 2-of-3. ElectionGuard’s specification warns that a low quorum makes it possible for that many guardians to “jointly decrypt individual ballots.” In our lab, 3-of-3 failed when one key was lost and resisted two colluders, while 2-of-3 survived the loss and let two trustees read one ballot.
- The proof requirement is right, and the hash input is the detail that decides it. ElectionGuard 2.1 hashes the ciphertext together with the proof’s commitments. Helios’s current code hashes only the commitments, with SHA-1, which is the variant a 2012 paper calls weak, and an issue to change it has been open in Helios’s repository since 2013. In a toy group, transcripts built by choosing the commitments first passed a commitment-only check 1000 of 1000 times and a statement-binding check 0 of 1000 times.
- The challenge arithmetic holds, and the measured rate sits at or below the primer’s assumption. The primer assumes that voters challenge one encrypted vote in one hundred. The IACR’s voided run shows 6 audited ballots posted beside 704 cast ballots (0.845 percent of prepared ballots, as a lower bound) and the rerun shows 2 beside 786 (0.254 percent).
- Chaining is real but optional. The specification defines a no-chaining mode and a simple-chaining mode, and the benefit it names is making it harder for an insider to delete ballots unseen. The 2023 College Park pilot lists ballot chaining among the features it did not use. In our toy chain, altering ballot 2 of 8 can be noticed by 7 voters with chaining and by 1 without it.
- The three ElectionGuard pilots were three different configurations. Fulton wrote no independent verifier, Preston used two guardians, and College Park ran a hybrid build that lacks several verifications.
- Left open: what the deployed Helios instance runs, whether an adaptive prover can exploit the commitment-only hash against a real ballot, and how many voters audit without posting.
What the primer says, and what we checked
| Claim in the primer | What we checked | Result |
|---|---|---|
| “Famously, it is used by the International Association for Cryptologic Research to elect its board of directors.” | The IACR’s November 21, 2025 update and the public Helios pages for both runs of its 2025 election | Holds. The IACR calls it an election “conducted using the Helios electronic voting system.” |
| Footnote 1: “A higher threshold is not always better.” | The IACR update, the Helios trustees page, ElectionGuard 2.1 section 3.2, the College Park page, our lab | Holds. The other side of the trade, a quorum set too low, is in the specification and in our lab. |
| “clients must include a zero-knowledge proof ensuring that the encrypted ballot contains a permitted value (or a permitted combination of values)” | The ElectionGuard 2.1 proof formula, Helios algs.py and elgamal.js, the 2012 Fiat-Shamir paper, our lab |
Holds. What the proof hash covers differs between the two systems. |
| Footnote 3: “if a compromised voting client tampers with 1000 votes, and voters challenge one encrypted vote in one hundred on average,” the attack stays undetected with probability 0.991000, about 4.3 × 10-5 | Recomputed, then compared with the audited-ballot lists of the two IACR runs | Holds as arithmetic. The measured rates are 0.845 and 0.254 percent of prepared ballots, and a 1000-ballot attack is not the hard case. |
| ElectionGuard “extends this idea to allow voters to detect a change in any of the ballots cast before theirs by chaining ballot commitments generated on the same device” | ElectionGuard 2.1 section 3.4.4, the College Park page, our toy chain | Holds for simple chaining on one device. Chaining is a mode, the benefit the specification names concerns deletion, and College Park did not use it. |
| ElectionGuard “has been tested alongside systems based on paper ballots in College Park Maryland, Preston Idaho, and Fulton Wisconsin” | The three pilot pages and the report to the Election Assistance Commission | Holds. The three pilots differ in guardians, verifier and features. |
| “Helios intentionally targets lower-risk and lower-coercion settings rather than high-stakes public elections” | Adida’s 2008 Helios paper | Holds. The paper calls Helios suited to environments where “coercion is not a serious concern.” |
The lab
The lab is four small modules, four step scripts and a test file, all standard-library Python 3.13 apart from pytest. Nothing in it opens a network connection. Its group is a toy: a 506-bit prime p with a subgroup of 255-bit prime order q, found by a deterministic search, so it shows the protocol logic and nothing about real security. ElectionGuard’s specification uses a 4096-bit p.
Exponential ElGamal, tallies and trustee keys
Helios and ElectionGuard encrypt each ballot choice with exponential ElGamal: a small number m becomes the pair (g^r, K^r · g^m). Multiplying two such pairs adds their plaintexts, so a server can add up encrypted votes and decrypt only the total. Decryption yields g^m rather than m, so the lab looks m up in a table of the first 20,001 powers of g. Anything outside the table comes back as None, which is how a ciphertext nobody can read shows up in the output below.
additive_keys gives every trustee a random share and multiplies the public pieces into the election key, so decryption needs every trustee’s contribution. shamir_keys splits one secret so that any t shares rebuild it. A trusted dealer does that here; real systems run a distributed key generation so that no single party ever holds the key. Our Shamir’s Secret Sharing tutorial covers the share arithmetic.
# evote.py
"""evote.py: toy exponential ElGamal over a Schnorr group, with all-trustees and Shamir trustee keys.
TOY PARAMETERS (505-bit p, 255-bit q): not secure, only here to run the protocol logic.
Nothing in this folder opens a network connection."""
import hashlib
import secrets
Q = 2**255 - 19 # prime order of the subgroup we work in
def is_prime(n):
small = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37)
if n < 2:
return False
for s in small:
if n % s == 0:
return n == s
d, r = n - 1, 0
while d % 2 == 0:
d, r = d // 2, r + 1
for a in small: # fixed Miller-Rabin bases: deterministic, fine for a toy
x = pow(a, d, n)
if x in (1, n - 1):
continue
for _ in range(r - 1):
x = x * x % n
if x == n - 1:
break
else:
return False
return True
def make_group():
k = 2**250
while not is_prime(k * Q + 1):
k += 2 # k stays even, so p = k*Q + 1 stays odd
p = k * Q + 1
return p, pow(2, (p - 1) // Q, p)
P, G = make_group()
def h_q(*parts):
"""Hash integers into Z_q: SHA-256 over length-prefixed big-endian encodings."""
h = hashlib.sha256()
for x in parts:
b = x.to_bytes((x.bit_length() + 7) // 8 or 1, "big")
h.update(len(b).to_bytes(4, "big") + b)
return int.from_bytes(h.digest(), "big") % Q
# ---- exponential ElGamal: E(m) = (g^r, K^r * g^m) -------------------------------------------
def keygen():
x = secrets.randbelow(Q - 1) + 1
return x, pow(G, x, P)
def encrypt(K, m, r=None):
r = secrets.randbelow(Q) if r is None else r
return (pow(G, r, P), pow(K, r, P) * pow(G, m, P) % P), r
def add(c1, c2):
"""Multiplying ciphertexts adds the plaintexts: the homomorphic tally."""
return (c1[0] * c2[0] % P, c1[1] * c2[1] % P)
def tally(cts):
t = (1, 1)
for c in cts:
t = add(t, c)
return t
def make_table(bound):
table, x = {}, 1
for m in range(bound + 1):
table[x] = m
x = x * G % P
return table
TABLE = make_table(20000) # decryption yields g^m, so we look m up in a table of small exponents
def unblind(ct, factor):
"""ct[1] / factor should equal g^m. Return m, or None when it is not a small exponent."""
return TABLE.get(ct[1] * pow(factor, -1, P) % P)
def decrypt(x, ct):
return unblind(ct, pow(ct[0], x, P))
# ---- trustees, variant 1: every trustee holds an additive share (all of them are required) ---
def additive_keys(n):
xs = [secrets.randbelow(Q - 1) + 1 for _ in range(n)]
K = 1
for x in xs:
K = K * pow(G, x, P) % P
return xs, K
def additive_factor(x_i, ct):
return pow(ct[0], x_i, P)
def combine_additive(ct, factors):
f = 1
for d in factors:
f = f * d % P
return unblind(ct, f)
# ---- trustees, variant 2: Shamir t-of-n shares (a trusted dealer here; real systems run a DKG) -
def shamir_keys(t, n):
coeffs = [secrets.randbelow(Q - 1) + 1] + [secrets.randbelow(Q) for _ in range(t - 1)]
shares = {i: sum(c * pow(i, j, Q) for j, c in enumerate(coeffs)) % Q for i in range(1, n + 1)}
return shares, pow(G, coeffs[0], P)
def lagrange_zero(ids, i):
num = den = 1
for j in ids:
if j != i:
num = num * (-j) % Q
den = den * (i - j) % Q
return num * pow(den, -1, Q) % Q
def combine_shamir(ct, factors):
"""factors maps trustee id -> ct[0] ** share. Needs at least t of them."""
ids = list(factors)
f = 1
for i in ids:
f = f * pow(factors[i], lagrange_zero(ids, i), P) % P
return unblind(ct, f)
Setting 1: how many trustees must show up
What the IACR record shows
The IACR’s November 21, 2025 update says that “the three members of the IACR 2025 Election Committee acted as independent trustees,” and that Helios’s design means “all trustees must provide their decryption shares.” Then: “one of the three trustees has irretrievably lost their private key, an honest but unfortunate human mistake.” The IACR concluded that “the only responsible course of action is to void this election and start a new election from scratch.” Its fix: “we will adopt a 2-out-of-3 threshold mechanism for the management of private keys.” The Register reported the same events three days later.
The public Helios pages agree. The voided election ran from October 17 to November 17, 2025 (UTC), with 704 ballots cast by an electorate of 2,521. Its trustees page still lists three trustees, two with a tally recorded and one with the line “waiting for this trustee’s tally.” The rerun ran from November 21 to December 20 with 786 ballots cast by an electorate of 2,454, and all three trustees have a tally recorded. The rerun page does not show whether a threshold was used, so we cannot say how the 2-of-3 plan was carried out.
The primer’s footnote 1 reaches the same conclusion: “A higher threshold is not always better. Helios requiring all trustees to participate to decrypt the election result caused the IACR to have to rerun their 2025 election after it found that one of the trustees had inadvertently lost their share of the key.”
The other side of the trade
Lowering the quorum fixes the lost-key problem and opens another. ElectionGuard’s specification gives typical values for the number of guardians and the quorum: “typical values for n and k could be 5 and 3.” Then it says why not to go lower: “The reason for not setting the quorum value k too low is that it will also be possible for k guardians to jointly decrypt individual ballots.” Its Note 3.2 adds that voters hold confirmation codes that can be tied to individual encrypted ballots, so “any group that has the ability to decrypt individual ballots can also coerce voters by demanding to see their confirmation codes.”
The College Park page for the 2023 pilot presents thresholding as new: “The biggest new capability is a full implementation of thresholding. Thresholding enables a quorum of guardians to participate in a tally ceremony rather than requiring all guardians to be present.” It adds: “College Park will be using 5 guardians with a quorum of 3 to be present to run the tally ceremony.”
Measured in the lab
step1_threshold.py builds five hidden votes that sum to 3, then asks each trustee setup three questions: does the tally decrypt with everyone present, does it decrypt with one trustee missing, and can two colluding trustees read a single voter’s ballot.
# step1_threshold.py
"""step1_threshold.py: what a lost trustee key and a colluding pair do under 3-of-3 and 2-of-3."""
from evote import (P, additive_factor, additive_keys, combine_additive, combine_shamir, encrypt,
shamir_keys, tally)
votes = [1, 0, 1, 1, 0]
print("hidden votes sum to", sum(votes))
print("\n== three trustees, all three required (the IACR 2025 setup) ==")
xs, K = additive_keys(3)
t = tally([encrypt(K, v)[0] for v in votes])
fs = [additive_factor(x, t) for x in xs]
print("all three present ->", combine_additive(t, fs))
print("trustee 2 lost their key ->", combine_additive(t, [fs[0], fs[2]]))
one, _ = encrypt(K, 1) # one voter's ballot, a yes
f1 = [additive_factor(x, one) for x in xs]
print("trustees 1 and 3 collude, read one ballot ->", combine_additive(one, [f1[0], f1[2]]))
print("\n== three trustees, any two suffice (the 2-of-3 fix the IACR announced) ==")
shares, K2 = shamir_keys(2, 3)
t2 = tally([encrypt(K2, v)[0] for v in votes])
fac = {i: pow(t2[0], s, P) for i, s in shares.items()}
for pair in ((1, 2), (1, 3), (2, 3)):
print(f"trustees {pair[0]} and {pair[1]} present ->", combine_shamir(t2, {i: fac[i] for i in pair}))
print("one trustee alone ->", combine_shamir(t2, {1: fac[1]}))
one2, _ = encrypt(K2, 1)
f2 = {i: pow(one2[0], s, P) for i, s in shares.items()}
print("trustees 1 and 2 collude, read one ballot ->", combine_shamir(one2, {1: f2[1], 2: f2[2]}))
hidden votes sum to 3
== three trustees, all three required (the IACR 2025 setup) ==
all three present -> 3
trustee 2 lost their key -> None
trustees 1 and 3 collude, read one ballot -> None
== three trustees, any two suffice (the 2-of-3 fix the IACR announced) ==
trustees 1 and 2 present -> 3
trustees 1 and 3 present -> 3
trustees 2 and 3 present -> 3
one trustee alone -> None
trustees 1 and 2 collude, read one ballot -> 1
With all three trustees required, losing trustee 2 leaves nothing readable (None), and trustees 1 and 3 together cannot read one ballot either. With a 2-of-3 split, any pair recovers the tally and one trustee alone gets nothing, but two trustees who collude read a single ballot (the 1 on the last line). The property the IACR says it will adopt to avoid another lost-key failure is the one the ElectionGuard authors warn about.
Setting 2: what the proof hash covers
What a ballot proof is for
Exponential ElGamal ciphertexts can be multiplied by anyone, so a voting client that encrypts 10,000 instead of 1 produces a ciphertext that adds into the tally like any other. The primer’s remedy is the proof: “clients must include a zero-knowledge proof ensuring that the encrypted ballot contains a permitted value (or a permitted combination of values).” Without it, “a compromised client could simply encrypt the value 1,000,000 to vote Yes a million times.” The lab uses 10,000 because its decryption table stops at 20,000.
fsproofs.py builds two proofs. prove_zero shows that a ciphertext encrypts 0 (a Chaum-Pedersen proof). prove_bit combines two of them, one per branch, to show that a ciphertext encrypts 0 or 1: the true branch is proved, the false branch is simulated, and the verifier cannot tell which is which. Both are made non-interactive with the Fiat-Shamir transformation, which replaces the verifier’s random challenge with a hash. The mode switch is the point of this section. The strong mode hashes the key, the ciphertext being proved and the commitments. The weak mode hashes the commitments only.
# fsproofs.py
"""fsproofs.py: Chaum-Pedersen and 0-or-1 proofs with a switch for what the Fiat-Shamir hash covers."""
import secrets
from evote import G, P, Q, encrypt, h_q
def challenge(mode, K, ct, commitments):
"""strong: hash the key, the ciphertext (the statement being proved) and the commitments.
weak: hash the commitments only."""
if mode == "strong":
return h_q(K, ct[0], ct[1], *commitments)
return h_q(*commitments)
# ---- "this ciphertext encrypts zero" (Chaum-Pedersen) ---------------------------------------
def prove_zero(K, r, mode):
ct = (pow(G, r, P), pow(K, r, P)) # an encryption of 0 with nonce r
u = secrets.randbelow(Q)
a, b = pow(G, u, P), pow(K, u, P)
c = challenge(mode, K, ct, (a, b))
return ct, (a, b, c, (u - c * r) % Q)
def verify_zero(K, ct, proof, mode):
a, b, c, v = proof
return (
c == challenge(mode, K, ct, (a, b))
and pow(G, v, P) * pow(ct[0], c, P) % P == a
and pow(K, v, P) * pow(ct[1], c, P) % P == b
)
def forge_zero_weak(K):
"""Under the weak hash the challenge does not depend on the statement, so pick the
commitments first, read off the challenge, choose v, then solve for the 'statement'."""
s, t, v = (secrets.randbelow(Q - 1) + 1 for _ in range(3))
a, b = pow(G, s, P), pow(K, t, P) # s != t, so the result will NOT be an encryption of 0
c = h_q(a, b)
c_inv = pow(c, -1, Q)
alpha = pow(a * pow(G, -v, P) % P, c_inv, P)
beta = pow(b * pow(K, -v, P) % P, c_inv, P)
return (alpha, beta), (a, b, c, v)
# ---- "this ciphertext encrypts 0 or 1" (the ballot proof: an OR of two Chaum-Pedersen proofs) --
def _branches(ct):
ginv = pow(G, -1, P)
return [ct[1], ct[1] * ginv % P] # branch i claims (alpha, beta / g^i) encrypts zero
def prove_bit(K, m, r, mode):
ct, _ = encrypt(K, m, r)
beta_i = _branches(ct)
j = 1 - m # the false branch cannot be proved, so it is simulated
cj, vj, u = (secrets.randbelow(Q) for _ in range(3))
comm = [None, None]
comm[j] = (pow(G, vj, P) * pow(ct[0], cj, P) % P, pow(K, vj, P) * pow(beta_i[j], cj, P) % P)
comm[m] = (pow(G, u, P), pow(K, u, P))
c = challenge(mode, K, ct, [x for pair in comm for x in pair])
cm = (c - cj) % Q
cs, vs = [0, 0], [0, 0]
cs[j], vs[j] = cj, vj
cs[m], vs[m] = cm, (u - cm * r) % Q
return ct, (comm, cs, vs)
def verify_bit(K, ct, proof, mode):
comm, cs, vs = proof
beta_i = _branches(ct)
if (cs[0] + cs[1]) % Q != challenge(mode, K, ct, [x for pair in comm for x in pair]):
return False
for i in (0, 1):
a, b = comm[i]
if pow(G, vs[i], P) * pow(ct[0], cs[i], P) % P != a:
return False
if pow(K, vs[i], P) * pow(beta_i[i], cs[i], P) % P != b:
return False
return True
What the two systems hash
ElectionGuard’s specification hashes the statement. For its zero-or-one ballot proof the challenge is c = H_q(H_I; 0x24, ind_c(Λ), ind_o(λ), α, β, a0, b0, a1, b1) (equation 41), where (α, β) is the ciphertext, the a and b values are the four commitments, the contest and option indices say which choice is being proved, and H_I binds the hash to the election and the ballot. The simpler Chaum-Pedersen proof in the same section uses c = H_q(H_I; α, β, a, b). The hash is built from HMAC-SHA-256. The specification also states why a prover must not know the challenge early: “if the prover knows a challenge value prior to making its commitment, it can create a false proof.”
Helios does it differently. Its server verifies each cast ballot by passing algs.EG_disjunctive_challenge_generator to the proof check (electionalgs.py, lines 187 and 188). On the repository’s master branch, commit a09a45b of November 3, 2026, that function and the comparison it feeds read:
# helios-server, helios/crypto/algs.py at commit a09a45b, lines 510 to 512 (verify_disjunctive_encryption_proof)
# check the overall challenge
return (challenge_generator([p.commitment for p in proof.proofs]) == (
sum([p.challenge for p in proof.proofs]) % self.pk.q))
# helios-server, helios/crypto/algs.py at commit a09a45b, lines 694 to 701
def EG_disjunctive_challenge_generator(commitments):
array_to_hash = []
for commitment in commitments:
array_to_hash.append(str(commitment['A']))
array_to_hash.append(str(commitment['B']))
string_to_hash = ",".join(array_to_hash)
return int(SHA1.new(bytes(string_to_hash, 'utf-8')).hexdigest(), 16)
The only input is the list of commitments, the A and B values, hashed with SHA-1. The ciphertext, the election key and the election are not in it. The repository’s browser-side crypto library says the same thing in a comment on its challenge function (elgamal.js, lines 514 to 516): “Just appends A and B with commas.”
That is the weak variant. The paper by Bernhard, Pereira and Warinschi, presented at ASIACRYPT 2012 and revised as ePrint 2016/771, defines it: “The strong variant then hashes both the commitment and the statement to be proved, whereas the weak variant hashes only the commitment.” Its warning: “in situations where malicious provers can select their statements adaptively, the weak Fiat-Shamir transformation yields unsound/unextractable proofs.” And its application to Helios: “for some standard types of elections, under plausible circumstances, malicious parties can cause the tallying procedure to run indefinitely and even tamper with the result of the election.”
The Helios maintainer’s own tracker has the change as a to-do. Issue 34, opened by the project’s author on March 10, 2013 and still open, is titled “rework the NIZK Fiat-Shamir Proofs to include extra information in the challenge,” and its description says: “This makes Helios provably secure and defends against some particularly advanced attacks.” The last comment, on January 4, 2026, says “we should include a voter UUID in here too.” The same algs.py file did receive a hardening change on August 15, 2026, “Check ElGamal group membership when verifying cast ballots (#495),” so this code has seen recent attention. The challenge hash is unchanged.
Measured in the toy group
step2_proofs.py checks three things. First, honest proofs pass in both modes, which is why a test suite that only uses honest provers cannot tell the two apart. Second, it builds transcripts the way the weak hash allows: pick the commitments, read off the challenge, pick a response, and solve for a statement that makes the checks pass. Third, it runs the ballot proof and shows what a tally does with and without the check.
# step2_proofs.py
"""step2_proofs.py: what the Fiat-Shamir hash input buys, and what a ballot proof is for."""
from evote import decrypt, encrypt, keygen, tally
from fsproofs import forge_zero_weak, prove_bit, prove_zero, verify_bit, verify_zero
x, K = keygen()
print("== honest 'encrypts zero' proofs (200 each) ==")
for mode in ("strong", "weak"):
ok = 0
for r in range(1, 201):
ct, proof = prove_zero(K, r * 7919, mode)
ok += verify_zero(K, ct, proof, mode)
print(f"{mode:>6} hash: {ok}/200 accepted")
print("\n== transcripts built by picking the statement AFTER the challenge (1000 tries) ==")
weak_ok = strong_ok = unreadable = 0
for _ in range(1000):
ct, proof = forge_zero_weak(K)
weak_ok += verify_zero(K, ct, proof, "weak")
strong_ok += verify_zero(K, ct, proof, "strong")
unreadable += decrypt(x, ct) is None # does the real key read a small number out of it?
print(f"accepted by the commitment-only hash: {weak_ok}/1000")
print(f"accepted by the statement-binding hash: {strong_ok}/1000")
print(f"forged ciphertexts the real key cannot decrypt to a small number: {unreadable}/1000")
print("\n== the ballot proof: this ciphertext encrypts 0 or 1 ==")
good = 0
for m in (0, 1) * 50:
ct, proof = prove_bit(K, m, 1234 + good, "strong")
good += verify_bit(K, ct, proof, "strong")
print(f"honest ballots accepted: {good}/100")
_, borrowed = prove_bit(K, 1, 99, "strong")
bad_ct, _ = encrypt(K, 10000)
print("ballot encrypting 10000 with a proof borrowed from an honest ballot accepted:",
verify_bit(K, bad_ct, borrowed, "strong"))
honest = [encrypt(K, v)[0] for v in (1, 0, 1, 1, 0)]
print("tally if the server never checks proofs:", decrypt(x, tally(honest + [bad_ct])))
print("tally if the server rejects the bad ballot:", decrypt(x, tally(honest)))
== honest 'encrypts zero' proofs (200 each) ==
strong hash: 200/200 accepted
weak hash: 200/200 accepted
== transcripts built by picking the statement AFTER the challenge (1000 tries) ==
accepted by the commitment-only hash: 1000/1000
accepted by the statement-binding hash: 0/1000
forged ciphertexts the real key cannot decrypt to a small number: 1000/1000
== the ballot proof: this ciphertext encrypts 0 or 1 ==
honest ballots accepted: 100/100
ballot encrypting 10000 with a proof borrowed from an honest ballot accepted: False
tally if the server never checks proofs: 10003
tally if the server rejects the bad ballot: 3
All 1000 transcripts built that way verify under the commitment-only hash and none verifies under the statement-binding hash. The forged ciphertexts are not encryptions of zero, and the real key cannot decrypt any of them to a small number, so each would be a ciphertext nobody can read with a proof that says it is fine. The ballot proof does its job when the hash binds the statement: the honest ballots pass, a ballot encrypting 10,000 with a borrowed proof fails, and a tally that skips the check reads 10003 where a tally that rejects the bad ballot reads 3.
What this does and does not show
The toy forges a simple statement, that a ciphertext encrypts zero, in a group with no security value. It shows why the hash input matters. It does not build a Helios ballot, and we did not try to. The paper describes how an adaptive prover uses the freedom against ballot proofs, and we did not reproduce that part or run anything against the Helios code or any Helios election. We also do not know whether the instance at vote.heliosvoting.org runs the code on master. The finding is narrower: the verifier in the public repository computes its challenge the way the literature calls weak, and the project’s own tracker has said since 2013 that it should change.
Setting 3: how many voters challenge
How the challenge is meant to work
The challenge, also called an audit or a spoil, is the primer’s answer to a compromised voting client. The client encrypts the ballot and shows the voter a commitment, and then the voter chooses to cast it or to open it for checking. ElectionGuard’s specification says when: “Once in possession of a confirmation code (and never before), a voter is afforded an option to either cast the associated ballot or challenge it and restart the ballot preparation process.” The report to the Election Assistance Commission on the Preston pilot explains why the order matters: requiring the encryption first “prevents a malicious or faulty system from varying how it will record ballots based on whether they are challenged or not.”
The two systems publish challenged ballots differently. ElectionGuard requires that “Each and every challenged ballot must be verifiably decrypted,” and the Preston report says that “Although challenged ballots are not included in the final published tally, they are released along with the election record.” Helios leaves posting to the voter. Its audited-ballots page says: “Once that’s done, you can post that opened ballot here, on the audited ballots’ list, for everyone to verify.” So the number of posted audits is a floor for the number of audits, not a count of them.
What the public Helios pages show
We counted the entries on each election’s audited-ballots page and the cast ballots on its voters page. The audit rate is the share of prepared ballots that were audited, with prepared ballots taken as cast ballots plus posted audits, because a voter who audits must prepare a fresh ballot.
| IACR 2025 election | Cast ballots | Audited ballots posted | Posted audits per prepared ballot |
|---|---|---|---|
| Voided run, October 17 to November 17 | 704 | 6 | 0.845 percent |
| Rerun, November 21 to December 20 | 786 | 2 | 0.254 percent |
| The primer’s assumption | not applicable | not applicable | 1 percent |
These electorates are drawn from a cryptology research association, whose members may be more inclined than most voters to audit, and the rates are lower bounds because audits can go unposted. We found no count of challenged ballots in the Preston report text, which reports confidence and usability findings, and the College Park page lists “Instant verification (BallotCheck)” among the 2.0 features that election did not use.
What a low rate does to detection
If each prepared ballot is challenged independently with probability a, a client that alters k ballots is caught with probability 1 - (1 - a)^k. auditmath.py has that formula, the number of altered ballots needed for a target chance of being caught, and the number of ballots an attacker must alter to turn a given margin: moving one vote from the leader to a rival shrinks the margin by 2, so the count is the margin divided by 2, rounded down, plus 1.
# auditmath.py
"""auditmath.py: how likely is a cheating voting client to be caught by a ballot challenge?"""
import math
def p_detect(a, k):
"""Each prepared ballot is challenged with probability a, independently. A client that
alters k ballots is caught if at least one altered ballot is challenged."""
return 1 - (1 - a) ** k
def flips_needed(margin):
"""Moving one vote from the leader to a rival shrinks the margin by 2."""
return margin // 2 + 1
def altered_for(a, target):
"""Smallest k with p_detect(a, k) >= target."""
return math.ceil(math.log(1 - target) / math.log(1 - a))
# step3_audit.py
"""step3_audit.py: detection power of ballot challenges at the audit rates the public Helios pages show."""
import random
from auditmath import altered_for, flips_needed, p_detect
# (cast ballots, audited ballots posted) read from the public pages of the two IACR 2025 elections
ELECTIONS = {"voided run": (704, 6), "rerun": (786, 2)}
RATE = {name: posted / (cast + posted) for name, (cast, posted) in ELECTIONS.items()}
RATE["primer's 1 in 100"] = 0.01 # the example in the Trail of Bits footnote
for name, (cast, posted) in ELECTIONS.items():
print(f"{name:<12} cast {cast}, audits posted {posted}: {RATE[name] * 100:.3f} percent of prepared ballots (lower bound)")
print(f"\nthe primer's footnote: 0.99 ** 1000 = {0.99 ** 1000:.2e}")
rng = random.Random(2026)
trials = 20000
names = list(RATE)
print(f"\n{'altered':>8} " + " ".join(f"{n:>17}" for n in names) + " simulated (voided run)")
for k in (1, 5, 10, 25, 50, 100, 200, 500, 1000):
caught = sum(any(rng.random() < RATE["voided run"] for _ in range(k)) for _ in range(trials))
print(f"{k:>8} " + " ".join(f"{p_detect(RATE[n], k):>17.3f}" for n in names) + f" {caught / trials:>8.3f}")
print("\naltered ballots needed for a given chance of being caught")
for target in (0.5, 0.9, 0.99):
print(f" {int(target * 100):>3} percent: " + ", ".join(f"{n} {altered_for(RATE[n], target)}" for n in names))
print("\nflipping a margin takes margin // 2 + 1 altered ballots")
for margin in (10, 50, 100):
k = flips_needed(margin)
print(f" margin {margin:>3}: {k:>2} ballots, caught with probability " +
", ".join(f"{p_detect(RATE[n], k):.3f}" for n in names))
voided run cast 704, audits posted 6: 0.845 percent of prepared ballots (lower bound)
rerun cast 786, audits posted 2: 0.254 percent of prepared ballots (lower bound)
the primer's footnote: 0.99 ** 1000 = 4.32e-05
altered voided run rerun primer's 1 in 100 simulated (voided run)
1 0.008 0.003 0.010 0.009
5 0.042 0.013 0.049 0.042
10 0.081 0.025 0.096 0.083
25 0.191 0.062 0.222 0.191
50 0.346 0.119 0.395 0.343
100 0.572 0.224 0.634 0.574
200 0.817 0.398 0.866 0.812
500 0.986 0.719 0.993 0.985
1000 1.000 0.921 1.000 1.000
altered ballots needed for a given chance of being caught
50 percent: voided run 82, rerun 273, primer's 1 in 100 69
90 percent: voided run 272, rerun 907, primer's 1 in 100 230
99 percent: voided run 543, rerun 1813, primer's 1 in 100 459
flipping a margin takes margin // 2 + 1 altered ballots
margin 10: 6 ballots, caught with probability 0.050, 0.015, 0.059
margin 50: 26 ballots, caught with probability 0.198, 0.064, 0.230
margin 100: 51 ballots, caught with probability 0.351, 0.122, 0.401
The primer’s 1000-ballot example is still a strong case for the challenge, but its 4.3 × 10-5 depends on the one-in-a-hundred assumption. At 1000 altered ballots the chance of being caught is 1.000 at the voided run’s rate and 0.921 at the rerun’s, which leaves the attack unseen about 8 percent of the time at the lower rate. A simulation of the voided run’s rate agrees with the formula to within about half a point at each row. The hard case is a small one. A 50-vote margin takes 26 altered ballots, and those are caught with probability 0.198 at the voided run’s rate, 0.064 at the rerun’s and 0.230 at one in a hundred. A client has to alter 272, 907 or 230 ballots before it is 90 percent likely to be caught, at the three rates. The primer’s footnote also raises the part a number cannot: “what recourse a voter has if tampering is detected.” A challenge that finds a cheating client still needs a procedure that lets the election react.
Setting 4: whether anyone checks the chain
The primer says ElectionGuard lets voters “detect a change in any of the ballots cast before theirs by chaining ballot commitments generated on the same device.” The specification describes the mechanism in section 3.4.4 and frames it more narrowly. “Hash chaining can be implemented in different modes,” and the two it defines are no chaining and simple chaining, where “a confirmation code only depends on the confirmation code of the previous ballot” plus the device information. The benefit it names: “The benefit of a chain is that it makes it more difficult for a malicious insider to selectively delete ballots and confirmation codes after an election without detection.” The chain is also closed at the end of the election by forming and publishing a closing code.
The College Park page lists, among “features that are outlined in the 2.0 spec that are not used in the College Park election,” the items “Ballot chaining,” “Pre-encrypted ballots,” and “Instant verification (BallotCheck).” So in the pilot whose page presents thresholding as new, the chain was not part of the election.
Measured in the toy chain
chain.py is a simplified version of the specification’s hash chain: HMAC-SHA-256 under a stand-in election hash, one-byte tags instead of four-byte fields, ballots as opaque bytes. step4_chain.py issues receipts for 8 ballots on one device, lets an attacker change the published record while recomputing every code (the inputs are all public, so recomputing costs nothing), and counts the voters whose receipt no longer appears in the record. The 5 percent lookup rate is our illustration, not a measured value.
# chain.py
"""chain.py: a toy version of ElectionGuard-style confirmation-code chaining on one device.
Simplified from the 2.1 specification: HMAC-SHA-256 keyed with a stand-in extended base hash,
one-byte mode and domain tags instead of the spec's 4-byte fields, ballots as opaque bytes."""
import hashlib
import hmac
HE = b"toy extended base hash".ljust(32, b".")
def H(*parts):
msg = b"".join(len(p).to_bytes(4, "big") + p for p in parts)
return hmac.new(HE, msg, hashlib.sha256).digest()
def confirmation_codes(ballots, chained):
"""Return (codes, closing_code). With chained=True code j also covers code j-1."""
start = H(b"\x29", b"\x01" + b"device-7") # initialization code H0
prev, codes = start, []
for b in ballots:
bc = b"\x01" + prev if chained else b"\x00" + b"device-7"
prev = H(b"\x29", H(b"\x20", b), bc)
codes.append(prev)
closing = H(b"\x29", b"\x01" + H(b"\x2b", prev, b"\x01" + start))
return codes, closing
def detectors(receipts, published_codes):
"""Voters (1-based) whose receipt code is missing from the published record."""
published = set(published_codes)
return [i + 1 for i, code in enumerate(receipts) if code not in published]
# step4_chain.py
"""step4_chain.py: who can notice that a record was changed, with and without chaining."""
from chain import confirmation_codes, detectors
N = 8
ballots = [f"encrypted ballot {i}".encode() for i in range(1, N + 1)]
CHECK = 0.05 # illustrative: the share of voters who look their receipt code up
def line(label, who):
p = 1 - (1 - CHECK) ** len(who)
print(f"{label:<44} voters who can notice: {who} chance someone does: {p:.3f}")
for chained in (True, False):
receipts, closing = confirmation_codes(ballots, chained)
print(f"\n== chaining {'ON' if chained else 'OFF'} ==")
altered = ballots[:1] + [b"a different ballot"] + ballots[2:]
published, _ = confirmation_codes(altered, chained) # the attacker recomputes every code
line("ballot 2 altered, codes recomputed", detectors(receipts, published))
removed = ballots[:2] + ballots[3:]
published, _ = confirmation_codes(removed, chained)
line("ballot 3 deleted, codes recomputed", detectors(receipts, published))
removed = ballots[:-1]
published, closing_after = confirmation_codes(removed, chained)
line("last ballot (8) deleted, codes recomputed", detectors(receipts, published))
if chained:
print(" closing code published before the deletion still matches:", closing == closing_after)
== chaining ON ==
ballot 2 altered, codes recomputed voters who can notice: [2, 3, 4, 5, 6, 7, 8] chance someone does: 0.302
ballot 3 deleted, codes recomputed voters who can notice: [3, 4, 5, 6, 7, 8] chance someone does: 0.265
last ballot (8) deleted, codes recomputed voters who can notice: [8] chance someone does: 0.050
closing code published before the deletion still matches: False
== chaining OFF ==
ballot 2 altered, codes recomputed voters who can notice: [2] chance someone does: 0.050
ballot 3 deleted, codes recomputed voters who can notice: [3] chance someone does: 0.050
last ballot (8) deleted, codes recomputed voters who can notice: [8] chance someone does: 0.050
With chaining on, altering ballot 2 changes every later code, so voters 2 through 8 can notice, and deleting ballot 3 can be noticed by voters 3 through 8. With chaining off, each change is visible only to the voter whose own code it touches. At a 5 percent lookup rate that moves the chance that somebody notices from 0.050 to 0.302 for the altered ballot. The blind spot is the last ballot on the device: deleting it can be noticed only by its own voter, unless somebody kept the closing code published beforehand, and then the closing code no longer matches. That fits the specification’s framing: the chain turns later voters’ lookups into checks on earlier ballots, and it needs the lookups to happen.
What the three ElectionGuard pilots covered
| Pilot | Guardians | Independent verifier | From the pilot’s own page |
|---|---|---|---|
| Fulton, Wisconsin, February 2020 | Three | None: “No independent verifier was written.” | “Three election guardians oversaw the ElectionGuard tally process.” The official tally was a hand count. |
| Preston, Idaho, November 2022 | Two | MITRE, which “wrote an independent verifier for this election” | “Two Election Guardians created the locks and keys to set up ElectionGuard.” The jurisdiction hand-counted the ElectionGuard ballots. |
| College Park, Maryland, November 2023 | Five, quorum of three | MITRE, with gaps | A hybrid build: “ElectionGuard 1.91.18 should not be used outside of the College Park election.” Verifications 6, 7.A and 7.B “were not implemented correctly so cannot be verified,” and verifications 11 and 14 “could not be implemented by an independent verifier.” Chaining and BallotCheck were not used. |
The College Park page also says that when the verifier first ran after the November 5 tally ceremony, a data error around the extended base hash made one verification fail. The cause was an equation formulated differently in the verifier and in the production code, and because the core code was frozen, the verifier was changed to interpret the hash correctly. The page discloses all of this, which is what makes the audit possible: the pilot’s limits are on the public record.
What the primer gets right
The primer’s premise is the consensus position. The National Academies’ 2018 report Securing the Vote says internet voting should not be used “until and unless very robust guarantees of security and verifiability are developed and in place, as no known technology guarantees the secrecy, security, and verifiability of a marked ballot transmitted over the Internet.” The ElectionGuard pilots ran beside paper ballots, and the official tallies came from a hand count (Fulton and Preston) or the Hart system (College Park). Adida’s Helios paper describes Helios as suited to environments where “coercion is not a serious concern.” Each of those matches what the primer says about where these systems belong.
A primer has to stay at the level of the design, and this one does. It states the proof requirement without saying what the hash covers, describes the chain without saying it is a mode, and gives the challenge arithmetic as an assumption. The footnotes carry the caveats that matter, and the public records supply the settings.
Five questions to ask about any electronic voting deployment
- How many trustees, with what quorum, and what is the recovery plan? The IACR’s 3-of-3 setup could not survive a lost key, and a low quorum lets that many trustees read individual ballots.
- What does the proof hash cover? Read the challenge function. If it hashes the commitments and not the statement being proved, the 2012 paper applies.
- How many audits happened in the last election, and who can see them? A published list of opened ballots is the clearest public evidence that the challenge is used in practice.
- Is chaining on, and is the closing code published and kept? Without both, nobody but its own voter can notice the removal of the last ballot on a device.
- Which independent verifier exists, and which verifications does it implement? College Park’s page lists what its verifier could not check, and that list is the useful part.
Checks on the lab itself
The test file has 11 pytest checks: the group is a Schnorr group, tallies add, 3-of-3 needs everyone, 2-of-3 accepts any pair and no single trustee, the weak hash accepts a statement chosen after the challenge and the strong hash rejects it, the strong challenge changes with the statement, bit proofs accept 0 and 1 and reject a borrowed proof and a tampered response, the chain counts match, and the audit formula matches a simulation. mutants.py plants six bugs one at a time in a copy of the lab and runs the tests against each. Our first version caught only four of six; the two it missed were a proof hash that drops the ciphertext (the forging function is written against the exact weak hash, so the old test could not see it) and a detection formula off by one (the simulation tolerance was too loose), and a new test plus a few exact-value assertions closed them.
# test_evote.py
"""test_evote.py: checks for the lab. Run with: python -m pytest -q"""
import random
from auditmath import altered_for, flips_needed, p_detect
from chain import confirmation_codes, detectors
from evote import (G, P, Q, additive_factor, additive_keys, combine_additive, combine_shamir, decrypt,
encrypt, is_prime, keygen, shamir_keys, tally)
from fsproofs import challenge, forge_zero_weak, prove_bit, prove_zero, verify_bit, verify_zero
def test_group_is_a_schnorr_group():
assert is_prime(Q) and is_prime(P)
assert (P - 1) % Q == 0 and G != 1 and pow(G, Q, P) == 1
def test_homomorphic_tally():
x, K = keygen()
votes = [1, 0, 1, 1, 0, 1]
assert decrypt(x, tally([encrypt(K, v)[0] for v in votes])) == sum(votes)
def test_three_of_three_needs_everyone_and_resists_two():
xs, K = additive_keys(3)
ct, _ = encrypt(K, 1)
fs = [additive_factor(x, ct) for x in xs]
assert combine_additive(ct, fs) == 1
for lost in range(3):
assert combine_additive(ct, [f for i, f in enumerate(fs) if i != lost]) is None
def test_two_of_three_decrypts_with_any_pair_but_not_one():
shares, K = shamir_keys(2, 3)
ct, _ = encrypt(K, 1)
fac = {i: pow(ct[0], s, P) for i, s in shares.items()}
for pair in ((1, 2), (1, 3), (2, 3)):
assert combine_shamir(ct, {i: fac[i] for i in pair}) == 1
for single in (1, 2, 3):
assert combine_shamir(ct, {single: fac[single]}) is None
def test_honest_zero_proofs_pass_in_both_modes():
_, K = keygen()
for mode in ("strong", "weak"):
ct, proof = prove_zero(K, 12345, mode)
assert verify_zero(K, ct, proof, mode)
def test_weak_hash_accepts_a_statement_chosen_after_the_challenge_and_strong_rejects_it():
x, K = keygen()
for _ in range(20):
ct, proof = forge_zero_weak(K)
assert verify_zero(K, ct, proof, "weak")
assert not verify_zero(K, ct, proof, "strong")
assert decrypt(x, ct) is None
def test_the_strong_challenge_depends_on_the_statement_and_the_weak_one_does_not():
_, K = keygen()
commitments = (5, 7)
assert challenge("strong", K, (2, 3), commitments) != challenge("strong", K, (2, 4), commitments)
assert challenge("weak", K, (2, 3), commitments) == challenge("weak", K, (2, 4), commitments)
def test_bit_proofs_accept_zero_and_one():
x, K = keygen()
for m in (0, 1):
ct, proof = prove_bit(K, m, 777 + m, "strong")
assert verify_bit(K, ct, proof, "strong")
assert decrypt(x, ct) == m
def test_bit_proof_rejects_a_big_value_and_a_tampered_response():
_, K = keygen()
ct, proof = prove_bit(K, 1, 5, "strong")
bad, _ = encrypt(K, 10000)
assert not verify_bit(K, bad, proof, "strong")
comm, cs, vs = proof
assert not verify_bit(K, ct, (comm, cs, [(vs[0] + 1) % Q, vs[1]]), "strong")
def test_chain_turns_one_check_into_many():
ballots = [f"b{i}".encode() for i in range(1, 9)]
receipts, closing = confirmation_codes(ballots, True)
altered = ballots[:1] + [b"x"] + ballots[2:]
assert detectors(receipts, confirmation_codes(altered, True)[0]) == [2, 3, 4, 5, 6, 7, 8]
plain, _ = confirmation_codes(ballots, False) # receipts issued by a device that does not chain
assert detectors(plain, confirmation_codes(altered, False)[0]) == [2]
last_gone = ballots[:-1]
pub, closing_after = confirmation_codes(last_gone, True)
assert detectors(receipts, pub) == [8]
assert closing != closing_after
def test_audit_math_matches_a_simulation():
a = 6 / 710
rng = random.Random(7)
trials = 20000
for k in (10, 100):
caught = sum(any(rng.random() < a for _ in range(k)) for _ in range(trials))
assert abs(caught / trials - p_detect(a, k)) < 0.01
assert p_detect(0.5, 1) == 0.5 and p_detect(0.5, 3) == 0.875 and p_detect(a, 0) == 0
assert altered_for(a, 0.5) < altered_for(a, 0.9) < altered_for(a, 0.99)
assert flips_needed(10) == 6 and flips_needed(1) == 1
# mutants.py
"""mutants.py: plant one bug at a time in a copy of the lab and confirm the tests notice."""
import os
import shutil
import subprocess
import sys
import tempfile
here = os.path.dirname(os.path.abspath(__file__))
MUTANTS = [
("proof hash forgets the ciphertext", "fsproofs.py",
"return h_q(K, ct[0], ct[1], *commitments)", "return h_q(K, *commitments)"),
("Lagrange numerator loses its sign", "evote.py",
"num = num * (-j) % Q", "num = num * j % Q"),
("chain ignores the previous code", "chain.py",
r'bc = b"\x01" + prev if chained', r'bc = b"\x01" + start if chained'),
("detection formula off by one", "auditmath.py",
"return 1 - (1 - a) ** k", "return 1 - (1 - a) ** (k - 1)"),
("additive combine keeps one factor", "evote.py",
" f = f * d % P", " f = d"),
("bit proof challenge split broken", "fsproofs.py",
"cm = (c - cj) % Q", "cm = c"),
]
caught = 0
for name, fname, old, new in MUTANTS:
with tempfile.TemporaryDirectory() as tmp:
for f in os.listdir(here):
if f.endswith(".py") and f != "mutants.py":
shutil.copy(os.path.join(here, f), tmp)
path = os.path.join(tmp, fname)
src = open(path, encoding="utf-8").read()
assert src.count(old) == 1, (name, src.count(old))
open(path, "w", encoding="utf-8", newline="\n").write(src.replace(old, new))
r = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x"], cwd=tmp, capture_output=True, text=True)
ok = r.returncode != 0
caught += ok
failing = [ln.split("::")[1].split(" ")[0] for ln in r.stdout.splitlines() if ln.startswith("FAILED")]
print(f"{'caught' if ok else 'MISSED':>6} {name:<36} {failing[0] if failing else ''}")
print(f"{caught} of {len(MUTANTS)} planted bugs caught")
........... [100%]
11 passed in 0.15s
caught proof hash forgets the ciphertext test_the_strong_challenge_depends_on_the_statement_and_the_weak_one_does_not
caught Lagrange numerator loses its sign test_two_of_three_decrypts_with_any_pair_but_not_one
caught chain ignores the previous code test_chain_turns_one_check_into_many
caught detection formula off by one test_audit_math_matches_a_simulation
caught additive combine keeps one factor test_three_of_three_needs_everyone_and_resists_two
caught bit proof challenge split broken test_bit_proofs_accept_zero_and_one
6 of 6 planted bugs caught
What we did not test
- We did not run, probe or attack any Helios or ElectionGuard deployment. The Helios pages we read are public election records.
- We read the Helios source on master at one commit and do not know what the deployed instance runs.
- We did not build a forged Helios ballot, and the toy forgery covers a simpler statement than a ballot proof.
- We did not read ElectionGuard’s software, only its specification, the pilot pages and the report to the Election Assistance Commission.
- Posted audits are a lower bound on audits, and the lab’s detection model assumes each prepared ballot is challenged independently with the same probability.
- The lab’s Shamir keys come from a trusted dealer, its chain is a simplified copy of the specification’s, and its group is a toy.
Sources
- Trail of Bits: The cryptography behind electronic voting (and why paper ballots are still essential), November 3, 2026
- IACR: Election 2025 Update, November 21, 2025; The Register: report on the rerun, November 24, 2025
- Helios public pages: the voided election’s election page, trustees, voters and audited ballots, and the rerun’s election page, voters and audited ballots, all read on November 4, 2026
- Helios server code at commit a09a45b: algs.py, electionalgs.py, elgamal.js and issue 34
- ElectionGuard: specification 2.1 (sections 3.2, 3.3.2, 3.4.4 and 3.6.7), the pilot pages for Fulton, Preston and College Park, and the report to the Election Assistance Commission
- Bernhard, Pereira and Warinschi: How Not to Prove Yourself: Pitfalls of the Fiat-Shamir Heuristic and Applications to Helios; Adida: Helios: Web-based Open-Audit Voting
- National Academies of Sciences, Engineering, and Medicine: Securing the Vote: Protecting American Democracy (2018)
- Our earlier coverage: Shamir’s Secret Sharing in Python, a tamper-evident audit log with hash chains and Trail of Bits’ SequenceHash








No Comment! Be the first one.