Anonymous, verifiable voting. Secret ballot, public proof.
Prestige is an anonymous, verifiable voting system built on unlinkable VOPRF tokens, commit-reveal schemes, and BFT timestamped attestations. Ballot secrecy is guaranteed — no one learns how anyone voted. Eligibility is enforced via gates. Double-voting is prevented via nullifiers and spent-token tracking. Anyone can verify the tally.
Ships as a web app (Express + PWA) and a CLI, backed by SQLite.
- Quick Start
- Demo Guide
- How It Works
- Features
- Voting Methods
- Gate System
- Architecture
- API Reference
- CLI Usage
- Configuration
- Privacy & Security
- Progressive Web App
- Audit & Verification
- Testing
- Troubleshooting
- License
Requires Node.js >= 20 (>= 20.10 for --env-file support).
No external services required. Uses mock adapters for Freebird (VOPRF tokens), Witness (BFT timestamping), and HyperToken (P2P relay). Open gates — anyone can create ballots and vote.
npm install
npm run build
NODE_ENV=development USE_MOCKS=true npm run webOpen http://localhost:3000 in your browser.
Why
NODE_ENV=development? Prestige refuses to start withUSE_MOCKS=truein production mode (see Troubleshooting). The.env.exampledefaults toNODE_ENV=production, so you must override it. In development mode,createTestPrestige()is used, which configures open gates, mock adapters, 60-minute ballot durations, and an in-memory store automatically.
Runs Prestige alongside real Freebird, Witness (3-node BFT cluster), and HyperToken Relay. Requires sibling repository checkouts:
dev/
prestige/
freebird/
witness/
hypertoken/
# Create local configuration
cp .env.example .env
# Generate required keys (run once per value):
node -e "const { randomBytes } = require('crypto'); console.log(randomBytes(32).toString('hex'))"
# Fill these in .env:
# PRESTIGE_PRIVATE_KEY — 32-byte hex Ed25519 private key
# FREEBIRD_ADMIN_KEY — strong random admin key
# FREEBIRD_SYBIL_MODE — invitation, proof_of_work, webauthn, or none (local testing only)
# BALLOT_GATE — owner, delegation, freebird, petition, or open
# VOTER_GATE — freebird, allowlist, or open
# For a local smoke test with open gates, also set:
# ALLOW_OPEN_GATES_IN_PRODUCTION=true
# Start all services
docker compose up --build
# Create a ballot
curl -X POST http://localhost:3000/api/ballot \
-H "Content-Type: application/json" \
-d '{"question": "Best framework?", "choices": ["React", "Vue", "Svelte"]}'
# Open the web UI
open http://localhost:3000Docker Compose runs in production mode (NODE_ENV=production is set in docker-compose.yml), so all production guards apply. Mock mode is not available in this path.
Run Prestige locally against real Freebird, Witness, and HyperToken services (started separately or via docker compose up freebird-issuer freebird-verifier witness-gateway):
npm install
npm run build
# Ensure .env has real service URLs and gate config
npm run webIf WITNESS_URL is set and NODE_ENV is not production, Prestige uses real adapters for configured services and mocks for any that are missing.
A 2-minute walkthrough of the full commit-reveal lifecycle.
NODE_ENV=development USE_MOCKS=true npm run webVia web UI: Open http://localhost:3000, create a ballot with question "Best framework?" and choices React, Vue, Svelte.
Via API:
curl -X POST http://localhost:3000/api/ballot \
-H "Content-Type: application/json" \
-d '{"question": "Best framework?", "choices": ["React", "Vue", "Svelte"], "durationMinutes": 1, "revealWindowMinutes": 1}'Use short durations (durationMinutes: 1, revealWindowMinutes: 1) so you don't have to wait 24 hours for the demo.
Open the ballot in the web UI and vote. The browser generates an Ed25519 keypair (stored in IndexedDB), requests an eligibility token, and submits a commitment H(choice || salt) with a nullifier H(secret || ballotId). The actual choice is not visible yet — only the hash.
With 1-minute durations, wait ~60 seconds. Check status:
curl http://localhost:3000/api/ballot/<ballot-id>/statusAfter the voting deadline passes, the reveal phase opens. Submit the choice + salt to prove the commitment was honest. The server verifies that H(choice || salt) matches the original commitment.
curl http://localhost:3000/api/results/<ballot-id>Results show the final tally with witness attestations. Use /verify for a full verification report, or /export/json and /export/csv for audit data.
You can also drive the demo from the terminal:
npm run cli -- create # interactive ballot creation
npm run cli -- list # list ballots
npm run cli -- vote <ballot-id> # cast a vote
npm run cli -- status <ballot-id> # check status
npm run cli -- reveal <ballot-id> # reveal after deadline
npm run cli -- results <ballot-id> # view tallyVoting Phase Deadline Reveal Phase Finalization
│ │ │ │
▼ ▼ ▼ ▼
Voters submit No more votes Voters reveal Tally computed
commitments + accepted choice + salt to from valid reveals,
nullifiers. prove commitment attested by witnesses
(choice hidden) was honest
- Voting Phase: Voters submit commitments
H(choice || salt)with nullifiersH(secret || ballotId)and eligibility proofs. - Deadline Passes: No more votes accepted.
- Reveal Phase: Voters reveal choice + salt, proving their commitment was honest.
- Finalization: Tally computed from valid reveals, attested by witness nodes.
| Property | Mechanism |
|---|---|
| Ballot Secrecy | Freebird VOPRF unlinkability — issuer can't connect token to verifier |
| No Double Voting | Nullifiers `H(secret |
| Eligibility | Freebird token verification — only authorized voters get tokens |
| Verifiability | Public commit-reveal scheme — anyone can audit |
| Timestamp Integrity | Witness BFT attestations — cryptographic proof of when votes were cast |
| Timing Attack Resistance | Random response delays in privacy mode |
| IP Privacy | Header stripping in privacy mode |
- Ballot Secrecy: No one learns how anyone voted (Freebird unlinkability)
- Eligibility: Only authorized voters can vote (caller-signed Freebird token requests)
- No Double Voting: One vote per eligible voter per ballot (nullifiers + one-time token spend tracking)
- Verifiability: Anyone can verify the tally is correct (public reveals + commitments)
- Coercion Resistance: Commit-reveal scheme prevents strategic voting
- Configurable Gates: Pluggable mechanisms for ballot creation and voter eligibility
- Multiple Voting Methods: Single choice, Approval, Ranked Choice (IRV), and Score voting
- Progressive Web App: Install on any device, vote offline, receive notifications
- Enhanced Privacy Mode: Timing attack protection, IP anonymization, Tor-friendly
- Audit Exports: Download ballot data as JSON or CSV for third-party verification
| Method | Description | Best For |
|---|---|---|
| Single Choice | Traditional one-person-one-vote | Simple yes/no or binary decisions |
| Approval Voting | Vote for all acceptable choices | Selecting from many similar options |
| Ranked Choice (IRV) | Rank choices in preference order | Eliminating vote-splitting, finding consensus |
| Score Voting | Rate each choice on a scale | Nuanced preference expression |
Uses Instant-Runoff Voting:
- Count first-choice votes
- If no majority, eliminate lowest candidate
- Redistribute eliminated candidate's votes to next preferences
- Repeat until majority winner emerges
Results display round-by-round elimination for full transparency.
Voters assign scores (e.g., 0-5) to each choice. Total scores determine winner; average scores shown for comparison.
"No one owns the mechanism, but someone owns each instance."
Prestige uses a two-layer gate system to control access.
| Gate | Description | Config |
|---|---|---|
open |
Anyone can create ballots | (none) |
owner |
Single admin key | BALLOT_GATE_ADMIN_KEY |
delegation |
List of authorized keys | BALLOT_GATE_DELEGATES |
freebird |
Token-gated creation | BALLOT_GATE_FREEBIRD_ISSUER |
petition |
Anyone proposes, activates at threshold | BALLOT_GATE_PETITION_THRESHOLD |
| Gate | Description | Config |
|---|---|---|
open |
Anyone can vote | (none) |
freebird |
Sybil-resistant via VOPRF tokens | Uses instance Freebird |
allowlist |
Specific keys only | VOTER_GATE_ALLOWLIST |
When using BALLOT_GATE=petition, a nested proposal gate controls who can open petitions:
| Gate | Description | Config |
|---|---|---|
voters |
Anyone who can vote can propose (default) | (none) |
delegation |
Specific keys only | PETITION_PROPOSAL_DELEGATES |
Voter requests token challenge (publicKey)
│
▼
┌───────────────────────┐
│ Signed Challenge │ ── Sign token:{ballotId}:{nonce}
│ (publicKey + signature│ with local Ed25519 identity
│ + nonce) │
└───────────────────────┘
│ valid
▼
┌───────────────────────┐
│ Instance Voter Gate │ ── Can this person vote HERE at all?
│ (configured by owner) │
└───────────────────────┘
│ yes
▼
┌───────────────────────┐
│ Ballot Eligibility │ ── Can this person vote on THIS question?
│ (set by ballot │ (can restrict, not expand)
│ creator) │
└───────────────────────┘
│ yes
▼
┌───────────────────────┐
│ Freebird Token Issue │ ── Anonymous proof of eligibility
└───────────────────────┘
│
▼
Vote cast with
unlinkable proof
| Model | Ballot Gate | Voter Gate | Use Case |
|---|---|---|---|
| Church | owner |
freebird |
Pastor sets agenda, verified members vote |
| Committee | delegation |
allowlist |
Board proposes, authorized members vote |
| Grassroots | petition |
freebird |
Anyone proposes, members activate and vote |
| Open forum | open |
open |
Anyone can create and vote (MVP testing) |
| Public poll | owner |
open |
Operator polls the public |
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ IndexedDB │ │ Crypto │ │ Vote UI │ │
│ │ (keypair) │ │ (commit/ │ │ (PWA) │ │
│ │ │ │ nullifier) │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Service │ │ Offline │ │
│ │ Worker │ │ Queue │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Prestige Server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Ballot │ │ Vote │ │ Reveal │ │
│ │ Manager │ │ Manager │ │ Manager │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tally │ │ Security │ │ Storage │ │
│ │ Manager │ │ Middleware │ │ (SQLite) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Freebird │ │ Witness │ │ HyperToken │
│ (Issuer + │ │ (Gateway + │ │ Relay │
│ Verifier) │ │ Cluster) │ │ (optional) │
└───────────────┘ └───────────────┘ └───────────────┘
HyperToken Relay is only needed for multi-node federation. Single-node deployments work without it.
GET /health # Service health check
GET /api/info # Instance info (public key, gate config)
GET /api/gates # Get gate configuration info
POST /api/gates/ballot/check # Check if key can create ballots
POST /api/gates/voter/check # Check if key can vote on instance
POST /api/ballot # Create ballot (requires gate check)
GET /api/ballot/:id # Get ballot details
GET /api/ballot/:id/status # Get status with vote count
GET /api/ballots # List all ballots
For ballots with petition gate:
POST /api/ballot/:id/petition # Sign petition to activate ballot
GET /api/ballot/:id/petition # Get petition status
POST /api/vote # Cast vote (commitment + nullifier + proof)
GET /api/votes/:ballotId # Get all commitments
POST /api/token/:ballotId/challenge # Request one-time challenge { publicKey }
POST /api/token/:ballotId # Request eligibility token { publicKey, signature, nonce, sybilProof? }
Token request flow:
- Call
/api/token/:ballotId/challengewithpublicKey. - Sign
token:{ballotId}:{nonce}with the same identity key. - Call
/api/token/:ballotIdwithpublicKey,signature, andnonce.
POST /api/reveal # Submit reveal (choice + salt)
GET /api/reveals/:ballotId # Get all reveals
GET /api/reveals/:ballotId/stats # Get reveal statistics
GET /api/results/:ballotId # Final tally with attestation
GET /api/results/:ballotId/live # Live tally during reveal phase
GET /api/results/:ballotId/verify # Verification report
GET /api/results/:ballotId/export/json # Download full audit data (JSON)
GET /api/results/:ballotId/export/csv # Download audit data (CSV)
GET /api/crypto/salt # Generate a random salt
GET /api/crypto/secret # Generate a random voter secret
GET /b/:id # Ballot page (shareable link)
GET /r/:id # Results page (shareable link)
The CLI uses the Prestige library directly (not the HTTP API). Run with npm run cli --:
# Create a ballot interactively
npm run cli -- create
# Vote on a ballot
npm run cli -- vote <ballot-id>
# Reveal your vote after deadline
npm run cli -- reveal <ballot-id>
# Check ballot status
npm run cli -- status <ballot-id>
# View results
npm run cli -- results <ballot-id>
# List recent ballots
npm run cli -- list
# Show gate configuration and your eligibility
npm run cli -- gates
# Sign a petition to activate a ballot (for petition gate)
npm run cli -- petition <ballot-id>
# Check your voting eligibility
npm run cli -- eligibility [ballot-id]
# Check service health
npm run cli -- healthIf prestige is globally installed (via npm link), you can use prestige <command> directly.
All configuration is via environment variables. Copy .env.example to .env and edit.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port |
NODE_ENV |
production |
Set to development for mock mode or local dev |
DATA_DIR |
/data |
SQLite database directory |
TOKEN_CHALLENGE_TTL_MS |
300000 |
Token challenge nonce TTL in ms (5 min) |
| Variable | Default | Description |
|---|---|---|
PRESTIGE_PRIVATE_KEY |
(none) | 32-byte hex Ed25519 private key. Required in production. Generate with node -e "const { randomBytes } = require('crypto'); console.log(randomBytes(32).toString('hex'))" |
PRESTIGE_PUBLIC_KEY |
(none) | Matching public key. Optional startup guard — verified against private key on boot |
| Variable | Default | Description |
|---|---|---|
FREEBIRD_ISSUER_URL |
http://localhost:8081 |
Freebird issuer endpoint |
FREEBIRD_VERIFIER_URL |
http://localhost:8082 |
Freebird verifier endpoint |
FREEBIRD_ISSUER_ID |
issuer:prestige:v4 |
Issuer identifier |
FREEBIRD_VERIFIER_ID |
verifier:prestige:v4 |
Verifier identifier |
FREEBIRD_VERIFIER_AUDIENCE |
prestige |
Verifier audience claim |
FREEBIRD_SYBIL_MODE |
invitation |
Sybil resistance mode: invitation, proof_of_work, webauthn, or none (local testing only) |
FREEBIRD_ADMIN_KEY |
(none) | Strong admin key for Freebird service. Required by docker-compose |
FREEBIRD_REQUIRE_TLS |
false |
Require TLS for Freebird connections |
| Variable | Default | Description |
|---|---|---|
WITNESS_URL |
http://localhost:8080 |
Witness gateway endpoint |
| Variable | Default | Description |
|---|---|---|
HYPERTOKEN_RELAY_URL |
(none) | WebSocket relay URL. If unset, HyperToken is disabled (single-node) |
| Variable | Default | Description |
|---|---|---|
DEFAULT_BALLOT_DURATION_MINUTES |
1440 |
Default voting duration (24 hours) |
REVEAL_WINDOW_MINUTES |
1440 |
Default reveal window (24 hours) |
MIN_DURATION_MINUTES |
1 |
Minimum allowed ballot duration |
MAX_CHOICES |
20 |
Maximum choices per ballot |
MAX_QUESTION_LENGTH |
500 |
Maximum question length (characters) |
| Variable | Default | Description |
|---|---|---|
BALLOT_GATE |
owner |
Gate type: open, owner, delegation, freebird, petition |
BALLOT_GATE_ADMIN_KEY |
instance key | For owner gate (defaults to instance public key) |
BALLOT_GATE_DELEGATES |
(none) | Comma-separated keys for delegation gate |
BALLOT_GATE_FREEBIRD_ISSUER |
(none) | Issuer ID for freebird gate |
BALLOT_GATE_FREEBIRD_ISSUER_URL |
(none) | Separate issuer URL for ballot gate |
BALLOT_GATE_PETITION_THRESHOLD |
10 |
Signatures needed to activate for petition gate |
ALLOW_OPEN_GATES_IN_PRODUCTION |
false |
Set true to allow open gates in production |
| Variable | Default | Description |
|---|---|---|
VOTER_GATE |
freebird |
Gate type: open, freebird, allowlist |
VOTER_GATE_ALLOWLIST |
(none) | Comma-separated keys for allowlist gate |
VOTER_GATE_FREEBIRD_ISSUER_URL |
(none) | Separate issuer URL for voter gate |
| Variable | Default | Description |
|---|---|---|
PETITION_PROPOSAL_GATE |
voters |
Gate type: voters or delegation |
PETITION_PROPOSAL_DELEGATES |
(none) | Comma-separated keys for delegation proposal gate |
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_WINDOW_MS |
60000 |
Rate limit window (1 minute) |
RATE_LIMIT_MAX_REQUESTS |
100 |
Max requests per window (general) |
RATE_LIMIT_REQUESTS |
30 |
Max requests per window for sensitive endpoints (vote, reveal, token) |
| Variable | Default | Description |
|---|---|---|
PRIVACY_MODE |
false |
Master switch for privacy features |
PRIVACY_MIN_DELAY_MS |
100 |
Minimum random delay on sensitive endpoints |
PRIVACY_MAX_DELAY_MS |
2000 |
Maximum random delay on sensitive endpoints |
PRIVACY_NORMALIZED_RESPONSE_MS |
0 |
All responses take at least this long (0 = use random delays instead) |
PRIVACY_BATCHING |
false |
Process votes/reveals in batches for unlinkability (increases latency) |
PRIVACY_BATCH_INTERVAL_MS |
5000 |
Batch processing interval |
DISABLE_LOGGING |
false |
Disable request logging for maximum privacy |
ONION_LOCATION |
(none) | Advertise Tor hidden service via Onion-Location header |
Token challenges are stored in-memory and expire automatically. They are cleared when the server restarts.
Enable for high-stakes anonymous voting:
PRIVACY_MODE=true
PRIVACY_MIN_DELAY_MS=100
PRIVACY_MAX_DELAY_MS=2000
DISABLE_LOGGING=false # Set true for maximum privacyFeatures:
- Timing obfuscation: Random delays on sensitive endpoints prevent timing attacks
- IP anonymization: Strips
X-Forwarded-Forand similar headers - Security headers: CSP, HSTS, X-Frame-Options, and more
- Privacy-aware rate limiting: Uses request fingerprints instead of IPs
Tor Hidden Service Deployment
Deploy Prestige as a Tor hidden service for maximum anonymity:
- Install Tor:
apt install tor - Configure
/etc/tor/torrc:HiddenServiceDir /var/lib/tor/prestige/ HiddenServicePort 80 127.0.0.1:3000 - Restart Tor and get your
.onionaddress:systemctl restart tor cat /var/lib/tor/prestige/hostname
- Set
ONION_LOCATIONto advertise your onion address - Enable
PRIVACY_MODE=trueandDISABLE_LOGGING=true
For high-security deployments, consider running on Tails or Whonix, enabling batching for additional unlinkability, and using separate Tor circuits for each voter gate check.
The web UI includes privacy guidance:
- Tor Browser usage recommendations
- VPN recommendations (Mullvad, ProtonVPN, IVPN)
- Device privacy best practices
- Timing attack mitigation tips
Prestige works as a Progressive Web App:
- Install on any device: Add to home screen on mobile or desktop
- Offline support: Queue votes/reveals when offline, sync when connected
- Local notifications: Get reminded when ballots are ending or reveals are due (requires permission)
- Fast loading: Service worker caching for instant access
Vote sync requires a pre-issued eligibility proof. If a vote was queued without
proof, replay is rejected.
- iOS: Safari → Share → Add to Home Screen (iOS 16.4+ required for notifications)
- Android: Chrome → Menu → Add to Home Screen
- Desktop: Chrome/Edge → Install button in address bar
Local notifications require user permission. On iOS, the PWA must be installed to the home screen before notifications can be enabled. The app schedules reminders for:
- Voting deadlines (1 hour before)
- Reveal deadlines (30 minutes before)
All votes and results are timestamped by witness nodes:
- Cryptographic signatures prove timestamp integrity
- Multiple witnesses for Byzantine fault tolerance
Download complete ballot data for independent verification:
- JSON Export (
/api/results/:ballotId/export/json): Full audit data including all votes, reveals, attestations, and cryptographic proofs - CSV Export (
/api/results/:ballotId/export/csv): Spreadsheet-friendly format with vote-level data and summary statistics
Exports include:
- All vote commitments and nullifiers
- All reveals with verification status
- Witness attestations and signatures
- Final tally computation data
# Run all tests
npm test
# Run integration tests only
npm run test:integration
# Run with coverage
npm test -- --coverage
# Run live-service contract tests (needs real Freebird/Witness/HyperToken)
npm run test:liveThe default test set covers crypto primitives, gate enforcement, and the full voting lifecycle (create → vote → reveal → tally) including double-vote and token-replay scenarios. Tests use InMemoryStore + mock adapters — no HTTP or external services required.
Note:
npm run devrunstsc --watchonly — it compiles but does not launch a server. To run the server in mock mode, useNODE_ENV=development USE_MOCKS=true npm run web.
Cause: NODE_ENV=production (or unset, which defaults to production behavior in some environments) and USE_MOCKS=true are both set. The server refuses to run with mock adapters in production mode — mock VOPRF tokens aren't unlinkable and mock witnesses don't provide real BFT timestamps.
Fix: Set NODE_ENV=development:
NODE_ENV=development USE_MOCKS=true npm run webCause: Running in production mode without required environment variables. Production requires: PRESTIGE_PRIVATE_KEY, FREEBIRD_ISSUER_URL, FREEBIRD_VERIFIER_URL, WITNESS_URL, BALLOT_GATE, VOTER_GATE.
Fix: Set the missing variables in .env, or switch to development mode for local testing.
Cause: BALLOT_GATE=open or VOTER_GATE=open in production mode without explicit opt-in. Open gates let anyone create ballots or vote — dangerous in production unless intentional.
Fix: If this is intentional (public poll mode), set ALLOW_OPEN_GATES_IN_PRODUCTION=true. Otherwise, use a restrictive gate (owner, delegation, freebird, allowlist).
Cause: The configured public key doesn't match the private key.
Fix: Either remove PRESTIGE_PUBLIC_KEY (it's optional — the server derives it from the private key), or regenerate it from your private key.
Cause: npm run dev is tsc --watch only — it compiles TypeScript but doesn't launch the server.
Fix: Use npm run build && NODE_ENV=development USE_MOCKS=true npm run web for mock mode, or run tsc --watch in one terminal and node --env-file=.env dist/web/server.js in another.
Cause: No ESLint configuration exists in the repo.
Fix: Create an eslint.config.* file first, or skip linting.
Apache-2.0