EXPERIMENTAL SOFTWARE — NOT FOR PRODUCTION USE. This code has not been audited and must not be used for real-world elections or any scenario where safety, legality, or physical risk is at stake.
Parallelia EC (ec) is the Electoral Commission daemon for the Parallelia voting system — an experimental, trustless, open-source electronic voting platform built in Rust.
It is responsible for:
- Managing elections — create, configure, and monitor elections via a gRPC admin API
- Issuing anonymous voting tokens — blind RSA signatures (RFC 9474) ensure the EC cannot link a vote to a voter
- Receiving votes over Nostr — all voter communication uses NIP-59 Gift Wrap (encrypted, anonymous)
- Publishing verifiable results — election announcements (Kind 35000) and results (Kind 35001) are published to Nostr relays
- Pluggable counting — plurality (FPTP) and STV (Single Transferable Vote) built-in, extensible via trait
- Rust toolchain 1.94+ (via rustup)
- Protocol Buffers compiler (
protoc) - SQLite development libraries
grpcurl(optional, for managing elections from the CLI)
# Ubuntu / Debian
sudo apt update
sudo apt install -y cmake build-essential libsqlite3-dev pkg-config libssl-dev protobuf-compiler ca-certificatesgit clone https://github.com/parallelia/ec.git
cd ec
cargo buildcp .env.example .envEdit .env and set at least:
NOSTR_PRIVATE_KEY=your_hex_nostr_private_key_hereThe Nostr private key is the EC's identity on the network. Generate one with any Nostr key tool or use openssl rand -hex 32 for testing.
Do I need to generate RSA keys? No. The EC uses two independent key systems, and you only supply the first:
Key Scope Where it comes from Purpose Nostr key One, for the whole daemon You provide it via NOSTR_PRIVATE_KEYThe EC's identity on the Nostr network; encrypts/signs NIP-59 Gift Wrap messages to voters RSA keypair One per election Generated automatically on AddElectionBlind-signs anonymous voting tokens (RFC 9474) The per-election RSA keypair is created for you inside the
AddElectioncall (crypto::generate_keypair, 2048-bit): the public key is published in the Kind 35000 announcement so voters can blind their nonces, and the private key is stored (wrapped inSecretString) to blind-sign tokens. You never generate, configure, or handle RSA keys manually — it all happens per election in the daemon.
Non-secret configuration lives in ec.toml (already included in the repo):
relay_url = "wss://relay.mostro.network"
grpc_bind = "127.0.0.1:50051"
rules_dir = "./rules"
log_level = "info"
db_path = "./ec.db"
# TLS for the gRPC admin API (PEM paths; set both or neither).
# tls_cert = "./ec-cert.pem"
# tls_key = "./ec-key.pem"When tls_cert/tls_key are set, the gRPC admin API serves TLS with that
certificate; plaintext clients are rejected. When unset, the API is plaintext —
keep grpc_bind on loopback in that case, or the admin token and generated
registration tokens cross the network unencrypted (the daemon logs a warning).
cargo runOn startup, the EC will:
- Load
.env(if present) andec.toml - Create the SQLite database at
db_path(if it doesn't exist) and run migrations - Connect to the configured Nostr relay
- Start the scheduler (30-second tick for election status transitions and vote counting)
- Start the Nostr listener (receives Gift Wrap messages from voters)
- Bind the gRPC admin API on
grpc_bind(default127.0.0.1:50051)
The core idea: the EC signs a voting token without seeing its content (blind signature). This makes it cryptographically impossible to link a vote back to the voter who requested the token.
┌─────────────┐ gRPC ┌─────────────────┐ Nostr ┌─────────────┐
│ Operator │──────────────►│ EC │◄──────────────►│ Voters │
│ (Admin) │ Port 50051 │ (Electoral │ NIP-59 Gift │ (Clients) │
│ │ │ Commission) │ Wrap Events │ │
└─────────────┘ └────────┬────────┘ └─────────────┘
│
┌────────┴────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ SQLite DB │ │ Nostr Relay │
│ (ec.db) │ │ (publish) │
└─────────────┘ └─────────────┘
Phase 1 — Setup (Operator)
- Create election via gRPC
AddElection— the EC generates a fresh RSA 2048-bit keypair (SHA-384, PSS, Randomized mode) for this election and publishes the election announcement to Nostr (Kind 35000) with the RSA public key. - Add candidates via gRPC
AddCandidate— only while the election status isopen. - Generate registration tokens via gRPC
GenerateRegistrationTokens— the EC returns a list of opaque, single-use tokens. Distribute these to voters out-of-band (email, Signal, in person, etc.).
Phase 2 — Registration (Voter → EC, via Nostr Gift Wrap)
- Voter registers by sending a
registermessage with theirregistration_token. The EC verifies the token is valid and unused, marks it as consumed, and authorizes the voter's Nostr public key for this election. The EC replies withregister-confirmed.
Phase 3 — Token Issuance (Voter → EC, via Nostr Gift Wrap)
- Voter generates a random nonce — 32 random bytes (
[u8; 32]). Computesh_n = SHA-256(nonce). - Voter blinds
h_nusing the election's RSA public key (from the Kind 35000 event) to produceblinded_nonce. The blinding factor is kept secret by the voter. - Voter sends
request-tokenwith theblinded_nonce(base64-encoded). - EC verifies the voter is authorized and hasn't already received a token. EC blind-signs the blinded nonce with the election's RSA private key and returns the
blind_signature. EC markstoken_issued = true— the voter cannot request another token.
Key insight: The EC signs the blinded message without knowing what it's signing. When the voter unblinds the signature, the EC cannot correlate the resulting token with the voter who requested it.
Phase 4 — Voting (Anonymous Voter → EC, via Nostr Gift Wrap)
- Voter unblinds the signature using the stored blinding factor → valid anonymous voting token. Voter also obtains the
msg_randomizer(32 bytes, per RFC 9474). - Voter creates a fresh, anonymous Nostr keypair — completely unlinked from their real identity.
- From the anonymous keypair, voter sends a
cast-votemessage containing:candidate_ids— the voter's choice(s):[1]for plurality,[3, 1, 4, 2]for ranked (STV)h_n— hex-encoded SHA-256 of the original noncetoken— base64-encodedsignature ++ msg_randomizer(signature bytes concatenated with 32-byte randomizer)
- EC verifies the token signature against the election's RSA public key, checks that
h_nhas not been used before (prevents double voting), validates the ballot against the election's rules, and records the vote. No voter identity is stored with the vote.
Phase 5 — Counting & Results
- The scheduler (30-second tick) monitors election timing. When
end_timeis reached, the election transitions tofinished. - The EC loads the election rules, invokes the counting algorithm, and publishes the results to Nostr (Kind 35001).
The EC provides a gRPC admin API for election management. You can use grpcurl or any gRPC client.
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{
"name": "Council 2026",
"start_time": 1711200000,
"end_time": 1711300000,
"rules_id": "plurality"
}' \
localhost:50051 proto.admin.Admin/AddElectionstart_time/end_time— Unix timestamps. The scheduler will transition the election toin_progresswhenstart_timearrives and tofinishedwhenend_timearrives.rules_id—"plurality"or"stv". Must match a.tomlfile in therules/directory.
The response includes the election_id and the rsa_pub_key (base64 DER) generated for this election.
Candidates can only be added while the election is open:
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>", "id": 1, "name": "Alice"}' \
localhost:50051 proto.admin.Admin/AddCandidategrpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>", "id": 2, "name": "Bob"}' \
localhost:50051 proto.admin.Admin/AddCandidateGenerate tokens to distribute to eligible voters:
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>", "count": 100}' \
localhost:50051 proto.admin.Admin/GenerateRegistrationTokensThe response contains a list of opaque, single-use tokens. Distribute one per voter via a secure channel.
# List all elections
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
localhost:50051 proto.admin.Admin/ListElections
# Get a specific election
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>"}' \
localhost:50051 proto.admin.Admin/GetElection
# List registration tokens and their usage status
grpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>"}' \
localhost:50051 proto.admin.Admin/ListRegistrationTokensgrpcurl -plaintext -import-path proto -proto admin/admin.proto \
-d '{"election_id": "<ELECTION_ID>"}' \
localhost:50051 proto.admin.Admin/CancelElectionThe full proto definition is at proto/admin/admin.proto.
All voter-EC communication is JSON inside NIP-59 Gift Wrap events (encrypted, sender-anonymous).
Every request may include an optional request_id (client-generated random string, max 64 bytes). The EC echoes it verbatim in the reply — success or error — so clients can match responses to in-flight requests and ignore Gift Wraps replayed by relays. Requests without a request_id get replies without one.
Register (use the token received from the operator):
{
"action": "register",
"election_id": "abc123",
"registration_token": "base64url_token_here",
"request_id": "random_hex_string"
}Request blind-signed token (after registration, send blinded nonce):
{
"action": "request-token",
"election_id": "abc123",
"blinded_nonce": "base64_blinded_hash_here"
}Cast vote (from a fresh anonymous Nostr keypair):
{
"action": "cast-vote",
"election_id": "abc123",
"candidate_ids": [1],
"h_n": "hex_sha256_of_nonce",
"token": "base64_signature_and_msg_randomizer"
}For ranked ballots (STV), candidate_ids is an ordered preference list: [3, 1, 4, 2].
The token field is the base64-encoded concatenation of the unblinded RSA signature bytes followed by the 32-byte msg_randomizer (per RFC 9474 Randomized mode).
Success responses:
{ "status": "ok", "action": "register-confirmed", "request_id": "echoed..." }
{ "status": "ok", "action": "token-issued", "blind_signature": "base64...", "request_id": "echoed..." }
{ "status": "ok", "action": "vote-recorded", "request_id": "echoed..." }Error responses:
{ "status": "error", "code": "ERROR_CODE", "message": "Human-readable description", "request_id": "echoed..." }request_id is present only when the request carried one.
| Error Code | Meaning |
|---|---|
ELECTION_NOT_FOUND |
No election with that ID |
INVALID_TOKEN |
Registration token is invalid or already used |
ALREADY_REGISTERED |
Voter pubkey already registered for this election |
NOT_AUTHORIZED |
Voter pubkey not authorized (didn't register) |
NONCE_ALREADY_USED |
This h_n was already used (double vote attempt) |
ELECTION_CLOSED |
Election is not in the correct state for this action |
INVALID_CANDIDATE |
Candidate ID not found in this election |
BALLOT_INVALID |
Ballot violates rules (too few/many choices, duplicates, etc.) |
UNKNOWN_RULES |
The rules_id doesn't match any known counting algorithm |
open ──────► in_progress ──────► finished
│ ▲
│ │
└──► cancelled │
(counting +
publish results)
open— Election created. Candidates can be added. Voters can register and request tokens.in_progress—start_timereached. Voters can cast votes. No more candidates can be added.finished—end_timereached. Votes counted, results published to Nostr (Kind 35001).cancelled— Election cancelled via admin API.
The scheduler checks every 30 seconds for status transitions.
Published when an election is created. Addressable event with d tag = election ID.
{
"election_id": "abc123",
"name": "Council 2026",
"start_time": 1711200000,
"end_time": 1711300000,
"status": "open",
"rules_id": "plurality",
"rsa_pub_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...",
"candidates": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}The rsa_pub_key is the base64-encoded DER public key that voters use to blind their nonces.
Published when counting is complete. Addressable event with d tag = election ID.
{
"election_id": "abc123",
"name": "Council 2026",
"rules_id": "plurality",
"elected": [1],
"tally": [
{ "candidate_id": 1, "votes": 42.0, "status": "elected" },
{ "candidate_id": 2, "votes": 31.0, "status": "active" }
]
}For STV elections, the result includes a count_sheet array with per-round tallies showing surplus transfers and exclusions.
Election rules are defined as TOML files in the rules/ directory. The rules_id passed to AddElection must match the filename (e.g., "plurality" loads rules/plurality.toml).
| Rules ID | Algorithm | Ballot Type | Description |
|---|---|---|---|
plurality |
First Past the Post | Single choice ([1]) |
N highest vote-getters win (configurable seats) |
stv |
Single Transferable Vote | Ranked ([3,1,4,2]) |
Weighted Inclusive Gregory method, Droop quota |
- Implement the
CountingAlgorithmtrait insrc/counting/ - Register it in
algorithm_for()insrc/counting/mod.rs - Add a
.tomlfile inrules/describing ballot format, seat count, and counting parameters
See rules/plurality.toml and rules/stv.toml for the full configuration schema.
Environment variable > ec.toml > Hardcoded default
| Variable | Required | Description |
|---|---|---|
NOSTR_PRIVATE_KEY |
Yes | Hex-encoded Nostr private key for the EC identity |
EC_DB_PASSWORD |
No | Password to encrypt per-election RSA keys (not yet implemented) |
EC_ADMIN_TOKEN |
No | Bearer token for the gRPC admin API. When set, every call must carry authorization: Bearer <token>; unset or empty disables auth (safe only on loopback) |
RELAY_URL |
No | Overrides relay_url from ec.toml |
GRPC_BIND |
No | Overrides grpc_bind from ec.toml |
RULES_DIR |
No | Overrides rules_dir from ec.toml |
LOG_LEVEL |
No | Overrides log_level from ec.toml |
DATABASE_URL |
No | Overrides db_path from ec.toml |
TLS_CERT |
No | Overrides tls_cert from ec.toml (PEM certificate for the gRPC admin API; requires TLS_KEY) |
TLS_KEY |
No | Overrides tls_key from ec.toml (PEM private key for the gRPC admin API; requires TLS_CERT) |
Secrets are never stored in ec.toml. They are loaded from environment variables only and kept in memory as SecretString.
For local development, use .env (loaded by dotenvy at startup). .env is gitignored and must never be committed.
Single Rust binary with three concurrent surfaces:
| Surface | Purpose |
|---|---|
| Nostr Listener | Subscribes to NIP-59 Gift Wrap events, unwraps, dispatches to handlers, replies via Gift Wrap |
| gRPC Admin API | Operator interface for election management (local, non-voter) |
| Scheduler | 30-second tick loop — election status transitions, vote counting, result publishing |
| Module | Purpose |
|---|---|
config.rs |
Hybrid config: ec.toml + env vars. Secrets in SecretString |
crypto.rs |
Blind RSA: keypair gen (2048-bit, SHA-384, PSS, Randomized), blind sign, verify |
db.rs |
All SQLite queries. Token + voter writes use transactions with rows_affected() checks |
types.rs |
Domain structs: Election, Candidate, RegistrationToken, AuthorizedVoter, Vote, UsedNonce |
state.rs |
AppState (db pool, nostr client, keys, config) shared via Arc |
rules/ |
Election rule loading from TOML files. ElectionRules struct |
counting/ |
CountingAlgorithm trait + implementations. algorithm_for() registry |
nostr/ |
Listener (Gift Wrap subscription), publisher (Kind 35000/35001), message types |
handlers/ |
Register, request-token, cast-vote handlers |
grpc/ |
tonic service: AddElection, AddCandidate, GenerateTokens, etc. |
scheduler.rs |
Status transitions + counting at election close |
SQLite with 7 tables across 3 migrations. Key design decisions:
votestable stores NO voter identity — onlyelection_id,candidate_ids(JSON array), andrecorded_at- Per-election RSA keypairs stored in
election_keystable (DER base64) - Registration token consumption and voter authorization use transactions with
rows_affected()checks to prevent race conditions
cargo build # Build
cargo test # Run all tests (159 tests)
cargo clippy --all-targets --all-features -- -D warnings # Lint (must pass clean)
cargo fmt # Format
cargo fmt -- --check # Check formattingTests live in tests/ as integration tests (plus a few unit tests for private
helpers in src/main.rs). They use in-memory SQLite databases and an
in-memory fake Nostr relay (tests/common/mod.rs) — no external services
are required. The fake relay speaks just enough NIP-01 (EVENT/OK/REQ/EOSE
plus broadcast) to exercise the real networking paths.
| Test file | What it covers |
|---|---|
nostr_listener_test.rs |
Full voter flow end-to-end over NIP-59 Gift Wrap: register → request-token → cast-vote, request_id correlation, malformed messages, undecryptable wraps |
scheduler_test.rs |
The real scheduler::run() loop: status transitions, counting, result publishing, retry-on-failure, tick errors |
grpc_admin_test.rs |
Every admin RPC (happy paths, validation, path-traversal rejection, DB failures) |
nostr_publisher_test.rs |
Kind 35000/35001 events, with/without STV count sheet, relay failures |
handler_*_test.rs |
Register / request-token / cast-vote handlers, every protocol error code, no internal-error leakage to voters |
counting_*_test.rs, ballot_validation_test.rs |
Plurality + STV algorithms: surplus transfers, exclusions, all tie-breaking modes (backwards/random/manual), unsupported options |
db_test.rs, crypto_test.rs, rules_test.rs, config_test.rs |
Query layer, blind RSA error paths, rules loading, hybrid config precedence |
main_binary_test.rs |
Daemon startup smoke tests (config, migrations, Nostr client, gRPC bind) driving the real binary to controlled exits |
request_id_correlation_test.rs |
Reply correlation contract for request_id |
Coverage is measured with cargo-llvm-cov
(LLVM source-based instrumentation, the de-facto standard for Rust):
# One-time setup
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov
# Terminal summary
cargo llvm-cov --summary-only
# Browsable HTML report (target/llvm-cov/html/index.html)
cargo llvm-cov --html
# Show exactly which lines are uncovered
cargo llvm-cov report --show-missing-linesCurrent status (cargo llvm-cov --summary-only):
| Metric | Coverage |
|---|---|
| Lines | 98.2% |
| Functions | 96.9% |
| Regions | 95.2% |
Twelve of sixteen source files are at 100% line coverage (config, counting/mod,
counting/plurality, crypto, db, handlers/*, nostr/messages, rules, …).
The remaining ~1.8% consists of branches that only occur under race conditions
(e.g. two schedulers competing for the same transition), defensive unreachable
code in STV, and tracing macro instrumentation artifacts — verified to execute
but misattributed by the coverage tooling.
A docker-compose.yml is included that runs the EC alongside a Nostr relay:
# Set your Nostr private key
export NOSTR_PRIVATE_KEY=your_hex_key_here
# Start EC + relay
docker compose up -dThis starts:
- nostr-rs-relay on port 8080
- EC daemon on port 50051 (gRPC), connected to the relay
Data is persisted in Docker volumes (ec-data, relay-data).
- Voter anonymity — Blind RSA signatures make it cryptographically impossible for the EC to link a vote to the voter who requested the token
- gRPC binds localhost by default — change
grpc_bindonly if you understand the risk - Secrets in env vars only — private keys are wrapped in
SecretStringand never logged - Per-election RSA keypairs — each election gets its own 2048-bit key, generated at creation
- Double vote prevention — nonce hashes (
h_n) are tracked per election; reuse is rejected - NIP-59 Gift Wrap — all voter-EC messages are encrypted and sender-anonymous on the wire
- Experimental — no formal security audit. Use only for research, testing, and education
- Single EC — central authority issues tokens. No threshold or multi-party setup
- No voter client — this repo is the EC only. The voter client is a separate project
- Language: Rust 1.94 (edition 2024)
- Async runtime: tokio 1.50
- Nostr: nostr-sdk 0.44.1 (NIP-59 Gift Wrap)
- Blind signatures: blind-rsa-signatures 0.17.1 (RFC 9474)
- Database: SQLite via sqlx 0.8.6
- Admin API: gRPC via tonic 0.14.5 / prost 0.14.3
- Config: toml, dotenvy, secrecy
- Criptocracia MVP — the original prototype (EC + voter client) that this project grew out of; since renamed Parallelia
- Parallelia Voter — the TUI voting client for this EC
This repository is experimental and unaudited. Use it only for research, testing, and education.
This project is licensed under MIT. See LICENSE for details.