A decentralized, serverless electronic voting system built on Hyperswarm and Electron. No server. No central database. Votes propagate peer-to-peer across a Kademlia-based DHT and are merged with a CRDT-style algorithm in memory.
- Architecture Overview
- Peer Discovery
- Writer Authorization
- CRDT State Merging
- Installation
- Running (Development)
- Flow Diagrams
- Failure Scenarios and Recovery
- IPC Architecture
- Message Protocol
- Tech Stack
The app runs in two roles:
| Role | Started with | Responsibilities |
|---|---|---|
| Creator | --create flag |
Generates the topic, creates the poll, broadcasts state, force-closes |
| Voter | (default) | Pastes the topic hex, joins the DHT swarm, votes once |
Hyperswarm uses a DHT running over UDP. Every node announces itself under a topic — a 32-byte buffer — and simultaneously queries for other nodes under the same topic.
Creator starts
│
▼
randomBuffer(32) → topic (32 bytes)
│
▼
topic.toString('hex') → "a3f9...c1d2" (64 hex chars, shown in UI)
│
├── swarm.join(topic, { client: true, server: true })
│ └── announces own address to DHT under topic
│ └── queries DHT for peers already under topic
│
└── Creator shows the 64-char hex in the UI
│
└── Human copies it and sends to voters (chat, QR, paper)
When a Voter pastes the hex and clicks Join:
Voter receives "a3f9...c1d2"
│
▼
Buffer.from(hex, 'hex') → topic (32 bytes)
│
▼
swarm.join(topic, { client: true, server: true })
│
├── DHT lookup: "who else announced this topic?"
│ └── Returns a list of (IP, port) tuples
│
└── TCP connections established to each discovered peer
│
└── HELLO handshake → STATE_SYNC → poll state propagates
Key properties:
- The DHT is global and public — no central rendezvous server needs to be operated
- The same 32-byte topic acts as both the address and the access token
- Peers discover each other within ~2–5 seconds on a local network; ~5–15 seconds over the internet
- If all peers disconnect and reconnect later using the same topic, discovery still works
| Action | Allowed roles | Enforcement location |
|---|---|---|
| Create a poll | Creator only | Worker handleLocalMessage + CLI --create flag |
| Cast a vote | Voter only | Worker castVote + handleLocalMessage |
| Force-close a poll | Creator only | Worker handleLocalMessage |
| Join a topic | Voter only | Worker handleLocalMessage |
The role is determined at startup by the presence of the --create CLI flag and is injected into the Bare worker as a JSON argument. It cannot be changed at runtime.
Before any interaction with the poll, every user must present a PKCS#12 (.p12) certificate:
User opens app
│
▼
Certificate overlay shown (blocks UI)
│
▼
User drags .p12 file + types PIN/password
│
▼
electron/main.js → node-forge
1. Parse DER-encoded PKCS12
2. Extract X.509 certificate
3. Check validity dates (notBefore / notAfter)
4. Extract CN (display name) and serialNumber (NIF/voter ID)
5. Challenge-response:
a. Generate 32 random bytes
b. Sign with private key from PKCS12
c. Verify with public key from the certificate
→ Proves the user actually holds the private key
│
▼
{ ok: true, name: "Alice", nif: "12345678A", issuer: "Demo CA" }
│
▼
Identity badge shown in UI
This ensures:
- One vote per voterId — duplicate votes from the same worker-side voter identity are ignored in memory
- Certificate expiry is checked — expired certs are rejected before any action
- Key possession is checked in the certificate verification flow — the UI verifies the certificate and its private key before showing the identity badge
Because there is no single authoritative server, every node maintains a local copy of the poll state. When two nodes connect, they exchange their state and merge it.
The merge function implements a grow-only CRDT with the following rules:
function mergePolls(local, remote) {
// 1. Different polls: prefer the open one, then the older one
if (local.id !== remote.id) { ... }
// 2. Same poll: union of all votes, first-seen choice per voter wins
const mergedVotes = { ...local.votes }
for (const [voterId, choice] of Object.entries(remote.votes)) {
if (!(voterId in mergedVotes)) mergedVotes[voterId] = choice
}
// 3. Closed status wins (once closed, cannot be reopened)
const closed = local.status === 'closed' || remote.status === 'closed'
// 4. Earlier closedAt wins
closedAt = Math.min(local.closedAt, remote.closedAt)
}Properties guaranteed:
- Idempotent: merging the same state twice produces the same result
- Commutative: merge(A, B) === merge(B, A)
- Monotone: the vote count never decreases; closed is a terminal state
- Vote-once: first-seen vote for a voter ID is kept; subsequent votes for the same ID are silently dropped
Gossip protocol: when a node receives a new vote from a peer, it re-broadcasts it to all its other connections. This ensures votes propagate through the mesh even if not every node is directly connected to every other node.
A ─── B ─── C (A not directly connected to C)
A votes → B receives → B re-broadcasts → C receives
- Node.js 20+ and npm
- Git
# 1. Clone
git clone <repo-url>
cd hackupc2026
# 2. Install dependencies
npm install
# 3. (Optional) Generate demo certificates
# Requires openssl in PATH
cd demo-certs
bash generate.sh # creates Creator.p12, Voter1–4.p12, password: 1234
cd ..Three separate terminal windows are needed to simulate a full election:
# Terminal 1 — Creator node (generates topic, creates poll)
npm run start:a
# Terminal 2 — Voter B
npm run start:b
# Terminal 3 — Voter C
npm run start:cEach script starts Electron with a separate --storage path so they behave as independent nodes on the same machine.
Workflow:
- In the Creator window: load
Creator.p12(password1234), create a poll - Copy the 64-char topic hex shown in the Creator window
- In each Voter window: load a
VoterN.p12(password1234), paste the topic, click Join - Vote from each voter window
- Watch live results update across all windows
To open DevTools for debugging: Ctrl+Shift+I in any window.
Creator DHT Voter A Voter B
│ │ │ │
│── randomBuffer(32) ───►│ │ │
│ topic = "a3f9..." │ │ │
│ │ │ │
│── swarm.join(topic) ──►│◄── swarm.join(topic) ───│ │
│ │◄── swarm.join(topic) ───────────────────────►│
│ │ │ │
│◄═══════════════════ TCP connection ══════════════►│ │
│◄══════════════════════════════════ TCP connection ════════════════════►│
│ │ ◄══════════════ TCP connection ══════│
│ (mesh: every peer connects to every other peer) │
│ │ │
│── HELLO ─────────────────────────────────────────►│ │
│── HELLO ──────────────────────────────────────────────────────────────►│
│◄─ STATE_SYNC (empty) ────────────────────────────│ │
│◄─ STATE_SYNC (empty) ─────────────────────────────────────────────────│
│ │ │
│ [User creates poll in UI] │ │
│── CREATE_POLL ────────────────────────────────────►│ │
│── CREATE_POLL ─────────────────────────────────────────────────────────►│
│ │ │
│ [Voter A sees poll, votes] │
│◄─ VOTE_CAST (voterId=A, option=0) ───────────────│ │
│── VOTE_CAST ──────────────────────────────────────────────────────────►│
│ [Voter B votes]
│◄─ VOTE_CAST (voterId=B, option=1) ─────────────────────────────────────│
│── VOTE_CAST ──────────────────────────────────────►│ │
│ │ │
│ [Timeout expires or force-close] │ │
│── POLL_CLOSED ────────────────────────────────────►│ │
│── POLL_CLOSED ─────────────────────────────────────────────────────────►│
│ │ │
│ All nodes show final results │ │
A voter who joins after votes have already been cast still gets the full state via STATE_SYNC:
[Poll already has 5 votes]
Creator Voter C (late)
│ │
│ [Voter C pastes topic, clicks Join]
│ │
│◄══════════════════════ TCP connection ══════════►│
│ │
│◄─ HELLO ─────────────────────────────────────────│
│ │
│── STATE_SYNC (poll + 5 votes) ──────────────────►│
│ │
│ [Voter C sees poll + existing results]
│ [Voter C votes]
│◄─ VOTE_CAST ─────────────────────────────────────│
│── VOTE_CAST ─────────────────────────────────────► (other peers)
Why this works: On every new TCP connection, both sides immediately send their full current poll state (STATE_SYNC). The receiver runs mergePolls(), which unions all votes — so the late joiner instantly catches up.
The creator is only a distribution point for the topic. Once voters have the topic and are connected to each other, the creator is not needed:
Creator Voter A Voter B Voter C
│ │ │ │
│══════════════════►│ │ │
│══════════════════════════════════════►│ │
│══════════════════════════════════════════════════════════►│
│ │══════════════════►│ │
│ │══════════════════════════════════════►│
│ │ │══════════════════►│
│ │ │ │
X [Creator crashes]
│ │ │ │
[Voters remain connected to each other]
│ │ │
[VOTE_CAST gossip continues between A, B, C]
│◄─ VOTE_CAST ──────│ │
│── VOTE_CAST ──────────────────────────►│
│ │◄─ VOTE_CAST ──────│
│◄─ VOTE_CAST ──────────────────────────│
│ │ │
[Poll closes by timeout — each node's local timer fires independently]
│ │ │
[Each node closes, results are identical]
Why this works:
- Each voter has a local copy of the full poll state including
endsAt - Each voter runs its own
setTimeoutfor the poll deadline - Votes are gossiped peer-to-peer without routing through the creator
- The only capability lost when the creator crashes is the ability to force-close before timeout
If a node was offline when the poll should have closed:
Timeline:
t=0 Poll created (endsAt = t+60s)
t=30 Node goes offline
t=60 Poll timeout fires on all OTHER nodes → POLL_CLOSED broadcast
t=90 Offline node comes back online
│
▼
Node reconnects to DHT (same topic still valid)
│
▼
Receives STATE_SYNC from peer
│
▼
mergePolls() sees remote.status === 'closed'
→ closed wins → local poll marked closed
│
▼
Node shows correct final results
If the node was completely isolated (no peers at all):
Node reconnects — no peers reachable yet
│
▼
schedulePollClose() fires with remaining = max(0, endsAt - Date.now())
If Date.now() > endsAt → remaining = 0 → closes on next event loop tick
User Renderer Main Process (node-forge)
│ │ │
│── drag .p12 file ─────►│ │
│── type PIN ────────────►│ │
│── click Verify ────────►│ │
│ │── ipcRenderer.invoke ────────►│
│ │ cert:verify {data, password}│
│ │ │
│ │ parse DER │
│ │ check dates│
│ │ extract CN │
│ │ challenge- │
│ │ response │
│ │ │
│ │◄── { ok, name, nif, issuer } ─│
│ │ │
│◄── identity badge ─────│ │
│ shown in UI │ │
│ │ │
│ [cert rejected] │ │
│◄── error message ───────│ │
│ UI stays locked │ │
Problem: Voter A votes, disconnects, and reconnects. Will their vote be counted?
Answer: Yes. The vote was already broadcast and received by all connected peers before the disconnect. When Voter A reconnects, STATE_SYNC is exchanged — Voter A's vote is already in the remote state. mergePolls() keeps it (first-seen wins).
Problem: Voters A and B are in separate network partitions. A votes option 0, B votes option 1. When the partition heals, which votes win?
Answer: Both. The CRDT union merges all votes. Each voter's choice is kept. Neither vote is lost or overwritten.
Problem: Voter A votes option 0, then sends a second vote for option 1.
Answer: The first vote wins. castVote() checks:
if (voterId in state.currentPoll.votes) {
publishState()
return // idempotent — no change
}The second vote is silently dropped on every node in the network.
Problem: The creator made a poll and shared the topic, then all nodes crashed. Can voters still join?
Answer: Partially. The DHT topic is still valid — any node that rejoins with the same 64-char hex will find each other. However, poll state is in-memory only. A restarted creator will have no poll, and voters will see an empty state. The creator must create a new poll.
Note: This is a known limitation. Implementing Autobase would solve it by replicating the append-only event log across all peers, making state recoverable from any surviving node.
Problem: Creator is offline when endsAt passes. Can the poll still close?
Answer: Yes. Every node runs its own schedulePollClose() timer locally. When the timer fires, the node marks the poll closed and broadcasts POLL_CLOSED to all connected peers. The first node whose timer fires triggers the close for the whole network. Since Math.min(closedAt) is used in the CRDT merge, the earliest close timestamp is preserved consistently.
Problem: A stale POLL_CLOSED message arrives referencing an old poll ID.
Answer: Silently ignored. The handler checks:
if (!state.currentPoll || message.pollId !== state.currentPoll.id) returnThe app has three separate JavaScript contexts, each isolated from the others:
┌────────────────────────────────────────────────────────────────┐
│ Renderer (Chrome sandbox) │
│ - window.bridge.* ← only API available │
│ - No require(), no Node APIs │
│ - Communicates via contextBridge only │
└─────────────────────┬──────────────────────────────────────────┘
│ contextBridge (serialized IPC)
┌─────────────────────▼──────────────────────────────────────────┐
│ Main Process (Node.js / Electron) │
│ - ipcMain handlers │
│ - node-forge certificate parsing │
│ - pear.run() spawns the Bare worker │
│ - Forwards worker IPC to renderer as Electron IPC events │
└─────────────────────┬──────────────────────────────────────────┘
│ Bare.IPC (binary stream, newline-delimited JSON)
┌─────────────────────▼──────────────────────────────────────────┐
│ Bare Worker (Bare runtime — NOT Node.js) │
│ - Hyperswarm, P2P networking │
│ - CRDT state machine │
│ - Only Bare-compatible modules │
└────────────────────────────────────────────────────────────────┘
All IPC messages are newline-delimited JSON. Partial chunks are buffered until a \n is found before parsing.
| Type | Fields | Description |
|---|---|---|
JOIN |
key: string |
Voter joins a swarm by 64-char hex topic |
CREATE_POLL |
question, options[], timeoutMs |
Creator creates a new poll |
CAST_VOTE |
optionIndex: number |
Voter casts a vote |
CLOSE_POLL |
reason?: string |
Creator force-closes the poll |
PING |
— | Liveness check |
| Type | Fields | Description |
|---|---|---|
AWAITING_TOPIC |
role, peerId |
Voter worker ready, waiting for topic |
READY |
role, topic, peerId |
Worker joined swarm |
STATE |
role, topic, poll, revision, peers |
Full state snapshot |
PEERS |
count, topic |
Peer count changed |
PONG |
topic, peerId, revision, role, peers, poll |
Response to PING |
error |
code, message |
Error from any operation |
| Type | Description |
|---|---|
HELLO |
Initial greeting, triggers STATE_SYNC from receiver |
STATE_SYNC |
Full poll state, sent on connect and in response to HELLO |
CREATE_POLL |
New poll broadcast by creator |
VOTE_CAST |
Vote, gossiped to all peers |
POLL_CLOSED |
Poll closed, gossiped to all peers |
| Component | Technology | Why |
|---|---|---|
| Desktop shell | Electron 40 | Cross-platform GUI, Node.js main process |
| P2P runtime | Bare (pear-runtime) | Lightweight JS runtime for workers, Hyperswarm-native |
| Peer discovery | Hyperswarm 4 | Kademlia DHT, NAT traversal, no server needed |
| State merging | Custom CRDT | Grow-only vote set, closed-wins, eventual consistency |
| Certificate auth | node-forge | PKCS12 parsing, X.509 validation, RSA challenge-response |
| Identity | X.509 CN + serialNumber | Display name + voter NIF extracted from cert |
| Encoding | b4a | Buffer ↔ hex utilities, Bare-compatible |