Storage-agnostic, privacy-preserving voting on linearly homomorphic threshold
ElGamal over BLS12-381. geg generalises two working systems — the Munich
Personalratswahl (staff-council election) and Snapshot X private voting — so that
the storage backend and the identity source become interchangeable
adapters behind ports, with no change to the voting protocol, the wire
format, or the audit procedure.
The same services run an entire election unchanged over three data-layer backends: an in-memory reference, a Postgres microservice, and a blockchain (BLS12-381 bulletin-board contracts on any EVM chain). Swapping backends is a configuration change, not a code change.
- Run it (docker-compose, both backends):
RUNNING.md - Smart contracts:
contracts/README.md
- Private ballots. Votes are exponential-ElGamal ciphertexts under an election public key; individual votes are never decrypted — only the homomorphic aggregate is.
- Threshold decryption. A
t-of-nkeyper committee is generated per election by a distributed key generation (DKG) ceremony; no single party ever holds the decryption key. Anytkeypers can jointly decrypt the tally (tIS the quorum:(2,3)is 2-of-3, andtmust be a strict majority); up totcompromised keypers learn nothing. - Weighted voting. The eligibility service assigns each voter's weight and binds
it into the signed
ATTESTATION_V1credential — the voter and the config never set it (the config only declaresweighted+ amaxWeightceiling, enforced at admission). The tally scales each ballot by its attested weight:Σ wᵢ·ctᵢper candidate. Weight 1 is the degenerate one-person-one-vote case. - Publicly auditable. Every stored artifact is self-verifying (zero-knowledge proofs + signatures). From public reads alone, anyone can recompute the DKG finalization, re-derive the admitted ballot set, re-verify every decryption share, re-run the recovery, and compare against the published result. Any mismatch is publishable evidence.
- Backend-agnostic. In-memory, Postgres, and blockchain adapters all satisfy a
single
ElectionDataLayerport and pass one shared conformance suite.
Register ──▶ DKG ──▶ Vote ──▶ Tally ──▶ Decrypt ──▶ Result
- Register. The admin publishes an immutable election config (candidates,
voting window,
(t, n)committee with keyper identities + URLs, weighting rules). The registry assigns a sequential election id. - DKG. The coordinator drives a 2-round Feldman VSS ceremony across the keyper
committee over authenticated HTTP, with confidential round-2 shares travelling
directly keyper→keyper (no single process ever sees all shares). Only the
public result — the election public key + per-member committee keys — reaches
the data layer. A finalized key exists iff
≥ tkeypers submit a byte-identical result. - Vote. Voters build ballots in-browser (plaintext and proof randomness never
leave the client) and submit them through the public API's ballot ingest during
the half-open window
[votingStart, votingEnd). A voter may re-cast until the window closes; the eligibility service stamps each credential with a monotonic per-(election, voter) nonce, and the tally keeps only the highest-nonce ballot, so a replayed old ballot can never override a genuine re-vote. - Tally. After
votingEnd, the tally aggregator runs deterministic ballot admission (producing an admitted set + typed exclusion reasons), computes the weighted homomorphic aggregate, and publishes it. - Decrypt. The aggregator triggers the keypers; each keyper re-checks the decryption preconditions against the data layer, produces its partial decryption share with a DLEQ proof, and submits it.
- Result. Given
tverified shares, the aggregator Lagrange-combines them and recovers the per-candidate totals by baby-step/giant-step within a bound derived from the admitted weights, then publishes the result. The election becomes immutable.
Derived state, never stored. No service owns a mutable state machine. Every
service and auditor re-derives the lifecycle state
(Registered → KeyReady → Voting → Tallying → Complete, plus Cancelled and
DKGFailed) from (data-layer facts, now). Tallying is unbounded — there is no
tally deadline, so a late committee never strands the election. Keypers never trust a
trigger — they re-verify preconditions themselves.
Three planes talk to storage exclusively through the ElectionDataLayer
port — never a chain or DB directly:
- Admin plane — Election Admin (sole config writer), DKG Coordinator daemon, Tally Aggregator daemon.
- User plane — Eligibility Service (issues the
ATTESTATION_V1credential and is the sole authority on voter weight + eligibility), the Public API's ballot ingest (with an on-by-default filter; the former standalone gateway is now a library on the API), the browser crypto SDK. - Committee plane —
nKeyper services: fresh DKG per election, precondition- guarded partial decryption, private state encrypted at rest.
ElectionDataLayerport — the bulletin board. Adapters: in-memory (reference + executable spec), database (HTTP microservice over Postgres), blockchain (web3 over Foundry contracts). All three pass one conformance suite. The data layer is trusted for availability only — a malicious backend can censor or hide, but can never forge an accepted artifact or an undetected wrong result.EligibilityServiceport — issues/verifiesATTESTATION_V1over(electionId, pseudonym, vk, weight, nonce). Issuance is adapter-specific (stub, wallet/EIP-712, OIDC, Wahlregister) and decides who may vote, at what weight, and allocates the per-(election, voter) re-vote nonce; verification is normative and pure. The bundled dev stub supports an optional allowlist (deny path) and a durable nonce store — seeRUNNING.md.
Integrator responsibility. Voter eligibility and who pays ballot gas on the
blockchain backend belong to the external service integrating geg, not to geg
itself. Eligibility is the port above; gas is config-only (on-chain submitVote is
authorized by msg.sender, so the integrator either sponsors submission with a
funded key or lets voters self-pay). Note the privacy coupling for address-derived
pseudonyms — self-pay puts the voter's address on-chain and can deanonymize the
ballot, so sponsored submission is the anonymity-preserving choice. See
RUNNING.md.
The bundled eligibility issuer is reference code, not a production service. The dev stub (
services/eligibility), the wallet adapter (adapters/eligibility_wallet), and the chain voting-power reader (adapters/voting_power) exist to show the shape of the port; a real deployment supplies its own hardened issuer. Because the eligibility service is the sole authority on weight, a production implementation MUST, at minimum:
- Pin the voting-power snapshot block (e.g. to the election's
votingStartblock) rather than reading"latest".chain_voting_power(...)defaults to"latest"for convenience; left unpinned, a voter can move tokens between wallets and vote twice with the same balance.- Clamp attested
weightto the election'smaxWeightand enforce eligibility durably (the bundled reissue/nonce guards are single-process).- Bind a freshness/expiry (and ideally a one-time nonce) into the wallet challenge so a captured signature can't be replayed to mint credentials.
geg's core verifies only the attestation's signature,weight ≤ maxWeight, and the bindings — it trusts the issuer for weight correctness by design.
Layered top-to-bottom so imports flow downward:
core/— the pure protocol kernel (no I/O):config,state(state derivation),admission(the correctness kernel),aggregation(weighted sum + recovery),authz,write_auth.crypto/— the BLS12-381 suite (ElGamal-G2, Schnorr-G1, DLEQ/OR/budget proofs, Feldman DKG, Lagrange + BSGS recovery).envelopes/— JSON transport envelopes + byte codecs (the interop contract).ports/— the abstraction seams (data_layer,eligibility,keyper_p2p).adapters/— backends behind the ports (memory,db/,chain/, eligibility).services/— one domain package per actor, each exposing a__main__when deployable.
| Backend | Adapter | Authorization | Notes |
|---|---|---|---|
| In-memory | geg.adapters.memory |
request signatures | Reference + executable spec; the conformance suite's baseline |
| Database | geg.adapters.db |
request signatures (verified server-side) | Flask microservice + HttpDataLayerClient over Postgres; jsonb envelopes, per-election stable ordering under a row lock |
| Blockchain | geg.adapters.chain + contracts/ |
transaction sender + meta-tx | web3 over the Foundry bulletin-board; one adapter instance per actor bound to that actor's key |
All three satisfy the same port and run the same services; only the deployment config differs.
Every actor is a deployable service (python -m geg.services.<name>):
| Service | Package | Role |
|---|---|---|
| Data layer | data_layer |
Uniform HTTP service fronting any backend via GEG_DATA_LAYER=memory|database|blockchain |
| Public API | api |
CORS-enabled HTTP surface for frontends / external callers (port 8500). Reads are backend-blind (through the data-layer service); also hosts ballot ingest (POST .../ballots, formerly the gateway) with an on-by-default (non-authoritative) filter — keyless on db, the funded submitVote sender on chain |
| Keyper (×n) | keyper |
Holds its identity + encrypted private state; runs DKG over HTTP; precondition-guarded /decrypt |
| Coordinator | coordinator |
Auto-DKG watcher: drives the DKG ceremony; relays keyper DKG/decryption writes to the data layer |
| Tally aggregator | tally_aggregator |
Polls for closed elections; admit → aggregate → trigger keypers → recover → publish result |
| Ballot admission | gateway |
Library (single-ballot filter) used by the API's ballot ingest; no standalone service |
| Admin | admin |
register/cancel as CLI and admin-only HTTP service, authorized by the admin wallet's EIP-191 signature over the request (no bearer token) |
| Eligibility | eligibility |
Standalone credential issuer (run separately): wallet-authenticated /attest, optional allowlist deny path, durable re-vote nonce store |
| Auditor | auditor |
Independent re-verification of a finalized election from public reads |
- Write authorization is unified to secp256k1 / Ethereum
ecrecoveracross every actor (admin, aggregator, API ballot ingest, coordinator, keyper). On the in-memory and database backends this is an EIP-191 request signature verified bygeg.core.authz; on chain it is the transaction sender. - Voter keys stay separate. Ballot and attestation keys are Schnorr over G1 (client-side), unrelated to the write-authz identities.
- Keypers hold no data-layer write path and no gas. They content-sign their DKG
result and decryption shares and POST
{payload, signature}to the coordinator, which relays them:- on the DB backend, the coordinator forwards the signed write to the data-layer service;
- on chain, admin/aggregator and the API's ballot ingest submit their
own transactions (
msg.sender), and only keyper writes are relayed as meta-transactions — the coordinator's relayer pays gas and the contractecrecovers the keyper as the true author. The relayer holds no on-chain role.
- Keyper bootstrap trust set. A keyper's HTTP API is fail-closed behind bearer
tokens installed via an X25519-sealed, secp256k1-signed (EIP-191)
/auth/bootstrapwith a replay guard. A keyper pins a set of trusted bootstrapper identities — the coordinator (drives DKG) and the tally aggregator (triggers decryption) — so each drives the keyper signing with its own key, and neither needs to hold the other's. (Consolidating this into a single keyper orchestrator is a planned phase-2 change.) - Threshold guarantee is real. DKG round-2 shares travel directly keyper→keyper; no single process observes all shares. Keyper secrets persist Fernet-encrypted at rest (key derived from the signing key), so a keyper survives the gap between DKG and decryption and reloads on restart.
- Group: BLS12-381. ElGamal ciphertexts + DKG in G2 (96-byte compressed); Schnorr signatures + attestations in G1 (48-byte compressed). Zcash byte format, subgroup-checked on deserialize.
- Encryption: exponential ElGamal
C1 = r·P2,C2 = r·mpk + m·P2; homomorphic by point addition. - Threshold:
t-of-nFeldman VSS DKG; partial decryptσ_k = msk_k·C1with a DLEQ proof; Lagrange interpolation at zero; baby-step/giant-step recovery. - Proofs: Fiat-Shamir over a Merlin-style transcript; keccak256 throughout (fixed for cross-language vector compatibility).
- Ballot validity: Variant A (OR proof over
{0..B}), mode exact (Σ = B), weighted. Variant B (bit decomposition) and mode atMost are specified and test-vectored but deferred in the reference implementation (conformance level 2).
Byte-for-byte TS↔Python compatibility is a protocol requirement, enforced by a
cross-language conformance-vector suite (not merely a CI convenience). The browser
crypto is the published npm package @shutter-network/urban-verified-crypto; the
Python side reimplements the same byte formats and verifies the same vectors.
The blockchain backend is a Foundry project under contracts/:
ElectionRegistry— admin-gated factory + index; assigns sequential election ids.KeyperSet— immutable committee: members + per-member HTTP URLs + threshold.Election— per-election state machine (DKG voting, ballots, decryption shares, aggregate, result), assembled from facet contracts.
All curve points are stored as raw bytes; there is no on-chain pairing or proof
verification — validation is the auditor's (off-chain) responsibility, which keeps
gas costs down and matches the availability-only trust model. Keyper writes support
meta-transaction variants (voteDKGResultSigned, submitDecryptionShareSigned) so
the relayer pays gas while the contract recovers the keyper as author. See
contracts/README.md for the full interface.
src/geg/
core/ # pure protocol kernel: config, state, admission,
# aggregation, authz, write_auth
crypto/ # BLS12-381 crypto suite (ElGamal-G2, Schnorr-G1, proofs, DKG)
envelopes/ # JSON transport envelopes + codecs
ports/ # abstraction seams: data_layer, eligibility, keyper_p2p
adapters/ # backends behind the ports: memory, db/, chain/, eligibility
services/ # one domain package per actor: keyper/, coordinator/,
# tally_aggregator/, gateway/, admin/, auditor/, data_layer/
contracts/ # Foundry bulletin-board contracts (blockchain backend)
deploy/ # docker-compose (db, chain-devnet, chain) — see RUNNING.md
scripts/ # env generation, sample voter, chain deploy helpers
tests/ # unit, conformance, and end-to-end suites (+ vectors/)
Clone with submodules (the Foundry contract dependencies live in
contracts/lib/ as pinned git submodules):
git clone --recurse-submodules <repo-url>
# already cloned without them? fetch with:
git submodule update --initpip install -e '.[dev,db,chain]'
pytestThe Python test suite (308 passing, 27 skipped) is the integration test across
all three backends — in-memory, Postgres-over-HTTP, and blockchain-over-Anvil —
including the multi-operator HTTP keyper path. The Postgres tests use a dockerized
database (docker compose -f tests/docker-compose.yml up -d) and the chain tests
use Anvil (Foundry); both skip cleanly if those aren't available.
The contracts have their own Foundry suite (55 tests):
cd contracts && forge testSee RUNNING.md for the full deployment guide. Two docker-compose
stacks are provided — a Postgres stack (docker-compose.db.yml) and a blockchain
stack (docker-compose.chain-devnet.yml for a throwaway Anvil devnet,
docker-compose.chain.yml for a real chain). Both have been driven through a
complete election by hand (register → DKG → vote → tally → decrypt → result),
producing identical results — the same services, the same wire format, only the
data layer differs.
Implemented end-to-end. All three data-layer backends satisfy one port; the same services run a full election on each, validated by the automated suite and by hand-driven multi-election runs on both the Postgres and chain stacks.
Admin register/cancel is authorized by the admin wallet's EIP-191 signature (no shared bearer token). Deferred beyond v1: keyper-set rotation/discovery, phase-2 consolidation of keyper orchestration, trust-minimizing the aggregate (keypers threshold-publish it), and an optional ballot meta-transaction.