Anonymous, cryptographically verifiable voting for FSEK meetings.
Rustsystem is a modern, zero-trust voting system built for F-sektionen at Lund University. Using BBS blind signatures over the BLS12-381 curve, the system guarantees that every eligible voter can vote exactly once — without the server ever learning who voted for what. Integrity and anonymity are enforced mathematically, not by policy.
This section explains how to run a meeting without going into implementation details. For the underlying mechanics, see Architecture.
Navigate to the Rustsystem home page and click Create Meeting. You will be asked to provide:
- A meeting title.
- Your name
- A password.
The password is important: each tally is saved on the server in a file that is encrypted with a public key derived from this password. This means only someone with the password can decrypt the tally file. This is a last-resort safeguard, if the meeting hosts forget to download the tally before closing the vote round, the file can still be recovered offline using the password. Under normal operation the host downloads the tally during the meeting and never needs the file at all.
After creation you will be logged in as the host and taken to the host dashboard.
From the host dashboard, use the Add Voter panel to create an invitation for each participant. Give the voter a name and click Add. The system creates a voter account immediately, but the voter is not yet logged in.
Each invitation generates a QR code and a unique link. Share either with the voter. When the voter scans the QR code or follows the link, their browser completes the login sequence automatically and they appear as active on the host dashboard.
Note: Voters who have been created but have never followed their login link are considered unclaimed. When a new vote round starts, all unclaimed voters are automatically removed so that no one can log in mid-vote.
When all voters are present, the host configures the vote round:
- Motion / question to vote on.
- Candidates or options (yes/no, list of names, etc.).
- How many options can be chosen.
- Whether to shuffle the candidate order option order.
Note: A blank option is always provided. Do not include this in your vote options.
Press Start vote round. The meeting is now locked — no new voters can join until the round ends.
Note: Voters could still be removed during voting, although this is highly discouraged for security reasons. A voter could have already acquired a valid signature at this point which would make them eligible to vote.
Before a voter can cast a ballot, they must register for the current round. The voter presses Register to vote in their browser. This sends a cryptographic commitment to the signing authority; the authority creates a blind signature which is used later. See Voting Architecture for why this step is necessary.
The voter sees the ballot with all available options and selects their choice(s).
The voter presses Submit. Their browser retrieves their credentials and sends the vote together with a cryptographic proof derived from the blind signature. The server verifies the proof and records the vote. The blind signature is then marked as spent — it cannot be used again.
The host dashboard shows vote progress in real time.
Once the host is satisfied that everyone has voted, they press Tally votes. The server finalizes the count, returns the results, and saves an encrypted copy of the tally on the server.
The results are shown on screen broken down by candidate with a separate count for blank votes. The tally is only visible on the admin page.
The host should download the tally before ending the vote round. The download button on the tally panel saves the tally in the desired format with the full results. This is the primary way to keep a record.
The encrypted backup on the server can be decrypted later using the meeting password if needed. See Tally Architecture for the decryption procedure.
After recording the results, the host presses End Round. This resets the voting state so that a new round can be started. The meeting is unlocked and voters may be added again.
When the meeting is finished, the host presses Close meeting. All in-memory state for the meeting is discarded. Encrypted tally files that were saved to disk remain on the server.
The close-meeting confirmation panel includes a Download tallies section. Before (or after) confirming the close, the host can enter the meeting password and click Download to fetch and decrypt every tally file that was saved during the meeting, receiving them as a single tallies.json file.
This is different from the per-round download described in Downloading the Tally: that button saves the results of the current round only, in whichever format the host selects. The close-panel download retrieves all rounds at once, decrypting them entirely in the browser using the same key derivation as at meeting creation. Nothing sensitive is ever sent back to the server.
Tip: Even if every round's tally was already downloaded individually, this button gives the host a convenient single-file archive of the entire meeting's voting history.
This section describes the technical structure of Rustsystem. For cryptographic details, see Cryptography.
Rustsystem is split into two backend services and one frontend:
| Component | Role |
|---|---|
| rustsystem-server | Manages meetings, voters, vote rounds, and tallies. Serves the frontend SPA. |
| rustsystem-trustauth | Acts as the blind-signing authority. Registers voters for vote rounds and issues blind signatures. |
| Frontend | React SPA served by the server. Performs all client-side cryptographic operations. |
The two backend services communicate with each other over a mutual TLS (mTLS) channel on internal ports (1444 and 2444). Neither service trusts the other without a valid certificate. The public-facing APIs use standard HTTPS, JWT and auth cookies.
┌──────────────────────────────────────────────────────────────────┐
│ Browser │
│ (React + @noble/curves) │
└──────────┬───────────────────────────────────────────┬───────────┘
│ HTTPS :1443 HTTPS :2443 │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ rustsystem-server │ │ rustsystem-trustauth │
│ (Axum, port 1443) │◄──────────────────►│ (Axum, port 2443) │
│ internal: 1444 │mTLS │ internal: 2444 │
└──────────────────────┘ └──────────────────────┘
(in-memory) (in-memory)
All meeting state is in-memory, there is no database. The only data written to disk is encrypted tally files.
There are two login flows: one for meeting creation and one for voter invitation. In both cases the user ends up with two JWT cookies — one for the server and one for trustauth. Both are required because:
- The server cookie identifies the user for meeting management operations.
- The trustauth cookie identifies the user for blind-signature operations.
- Trustauth must verify that the user actually exists in the server's voter list before issuing any signatures.
sequenceDiagram
participant B as Browser
participant S as Server
participant T as Trustauth
B->>S: POST /api/create-meeting (title, pubkey)
S-->>B: Host JWT cookie (server)
B->>T: POST /api/login
T->>S: GET /is-voter
S-->>T: OK
T-->>B: Host JWT cookie (trustauth)
sequenceDiagram
participant H as Host Browser
participant S as Server
participant V as Voter Browser
participant T as Trustauth
H->>S: POST /api/host/start-invite (name)
S-->>H: QR code + login link (contains Ed25519 signature)
H->>S: SSE /api/host/invite-watch
H-->>V: Share QR / link
V->>S: POST /api/login (link token)
S->>S: Verify UUID, issue server JWT
S-->>V: Voter JWT cookie (server)
V->>T: POST /api/login
T->>S: GET /is-voter
S-->>T: OK
T-->>V: Voter JWT cookie (trustauth)
S-->>H: Login complete notification
The login link contains the meeting UUID and the new voter UUID. The server will check this against its record and accept the voter (which claims the UUID) if the UUID is valid and has not already been claimed.
The voting flow is the core of Rustsystem's security model. The key insight is:
Trustauth knows who is eligible but never learns what they voted for. The server knows what was voted but never learns who voted. Neither can (even in principle) piece together the full picture.
This is achieved through BBS blind signatures. See Cryptography — BBS Blind Signatures for how they work.
sequenceDiagram
participant B as Browser
participant T as Trustauth
participant S as Server
B->>B: Generate commitment, token, blind_factor, context
B->>T: POST /api/voter/register (commitment, context)
T->>S: mTLS — is vote active? is voter eligible?
S-->>T: OK
T->>T: Check voter not already registered
T->>T: Create blind signature
T-->>B: Blind signature
B->>B: Store blind signature locally
sequenceDiagram
participant T as Trustauth
participant B as Browser
participant S as Server
B->>B: Choose vote option
B->>T: GET /api/vote-data
T-->>B: OK
B->>S: POST /api/voter/submit (vote, proof, token, signature)
S->>S: Verify proof against trustauth public key
S->>S: Mark signature as spent (prevent double voting)
S->>S: Record vote
S-->>B: OK
Notice that the submission goes directly to the server, not through trustauth. The server only holds the trustauth public key. It can verify the proof without ever contacting trustauth or knowing which voter submitted it.
| Property | How it is enforced |
|---|---|
| One vote per voter | Trustauth issues exactly one blind signature per voter per round. The server marks each signature as spent on use. |
| Anonymity | The server never sees the voter's identity during submission. The proof is unlinkable to the registration request. |
| Eligibility | Trustauth checks with the server that the voter is a member of the meeting and that voting is active before signing. |
When the host calls Get Tally, the server finalizes the vote count and saves the result as an encrypted file.
The tally is encrypted using the X25519 public key that the host provided at meeting creation time (derived from their password — see X25519 Tally Encryption). The private key never reaches the server. This means:
- The server cannot read the tally file itself.
- Anyone with access to the server filesystem cannot read past tallies without the meeting password.
The file is written to meetings/{meeting-id}/tally-{timestamp}.enc on the server.
While the meeting is still open, the host can download every tally file at once from the close-meeting panel (see Downloading All Tallies at Close). The browser calls GET /api/host/get-all-tally, which returns all tally-*.enc files as base64-encoded payloads. The browser then:
- Derives the X25519 private key from the meeting password using PBKDF2-HMAC-SHA256 (same derivation as at meeting creation).
- Performs X25519 ECDH with each file's ephemeral public key.
- Derives the decryption key with HKDF-SHA256.
- Decrypts with ChaCha20-Poly1305.
- Delivers a single
tallies.jsoncontaining all decrypted records.
The private key is derived and used entirely inside the browser; it is never transmitted.
If the meeting has already been closed and the tallies were not downloaded in time, individual .enc files can still be decrypted offline using the decrypt-tally CLI:
- Retrieve the
.encfile(s) frommeetings/{meeting-id}/on the server. - Derive the private key from the meeting password:
SALT_HEX="<prod-salt>" KEYGEN_ITERATIONS=200000 ./scripts/derive-keys.sh <password> # Outputs: private_x25519.pem, public_x25519.pem
- Decrypt using the
decrypt-tallycrate:cargo run --package decrypt-tally /path/to/tally-<timestamp>.enc /path/to/private_x25519.pem # Outputs JSON tally to stdout
The SALT_HEX and KEYGEN_ITERATIONS values must match those used when the meeting was created. They are available in this repository.
Rustsystem uses a two-level locking strategy throughout to allow maximum concurrency while preventing data races.
All meeting state lives in ActiveMeetings:
Arc<AsyncRwLock<HashMap<MUuid, Arc<Meeting>>>>
The outer AsyncRwLock wraps the map of all meetings. Inside each entry is an Arc<Meeting> whose fields carry their own individual locks.
The outer map is held in write mode only when the map itself changes:
| Operation | Outer lock |
|---|---|
| Create meeting | Write |
| Close meeting | Write |
| Everything else | Read (just long enough to clone the Arc<Meeting>) |
The AppState::get_meeting() helper encapsulates the common case: it acquires a read lock, clones the Arc<Meeting>, and releases the lock before returning. Callers then work with the Arc without holding the outer lock at all.
pub struct Meeting {
pub title: String, // immutable after construction — no lock
pub start_time: SystemTime, // immutable after construction — no lock
pub locked: AtomicBool, // simple flag — atomic, no lock
pub voters: AsyncRwLock<HashMap<Uuid, Voter>>,
pub vote_auth: AsyncRwLock<VoteAuthority>,
pub invite_auth: AsyncRwLock<InviteAuthority>,
pub admin_auth: AsyncRwLock<AdminAuthority>,
}Each authority is locked independently. Operations that only need vote_auth do not block operations that only need voters, and vice versa.
When an operation must acquire more than one field lock, it always does so in this order to prevent deadlock:
votersvote_authinvite_authadmin_auth
Operations that currently acquire multiple locks:
| Endpoint | Locks acquired (in order) |
|---|---|
start-vote |
vote_auth.write → voters.write (safe: nothing holds voters.write and waits for vote_auth) |
login |
voters.write → invite_auth.write → admin_auth.write |
new-voter |
voters.write → admin_auth.write |
reset-login |
voters.write → admin_auth.write |
start-vote acquires vote_auth.write before voters.write to make the "check inactive, then start" sequence atomic. This does not violate the ordering because no other operation holds voters.write and then waits for vote_auth.write.
All round state lives in ActiveRounds:
Arc<AsyncRwLock<HashMap<Uuid, Arc<RoundState>>>>
Same two-level pattern as the server: the outer AsyncRwLock wraps the map of all rounds; inside each entry is an Arc<RoundState> whose mutable field carries its own lock.
| Operation | Outer lock |
|---|---|
| Start round | Write |
| Everything else | Read (just long enough to clone the Arc<RoundState>) |
AppState::get_round() acquires a read lock, clones the Arc<RoundState>, and releases the lock before returning.
pub struct RoundState {
pub keys: AuthenticationKeys, // immutable after construction — no lock
pub header: Vec<u8>, // immutable after construction — no lock
pub registered_voters: AsyncRwLock<HashMap<Uuid, VoterRegistration>>,
}keys and header are set once by start-round and never modified. Only registered_voters needs a lock.
| Endpoint | registered_voters lock |
|---|---|
register |
Write |
is-registered |
Read |
vote-data |
Read |
The core of Rustsystem's anonymity guarantee is BBS blind signing (draft-irtf-cfrg-bbs-blind-signatures-02).
BBS signatures are pairing-based signatures defined over the BLS12-381 elliptic curve. The "blind" variant lets a client ask for a signature over a message that is hidden from the signer. The signer cannot see what they are signing, yet the resulting signature is fully verifiable by anyone with the public key.
In Rustsystem this works as follows:
- The voter's browser generates a secret token and a Pedersen commitment (a hiding, binding commitment to the token). The commitment is sent to trustauth; the token stays in the browser.
- Trustauth verifies eligibility and signs the commitment — without ever seeing the token.
- The browser uses the blind factor and the blind signature to produce a standard BBS proof of knowledge that can be verified against trustauth's public key.
- The server verifies the proof using only the public key. It cannot tell which voter produced the proof.
The ciphersuite used is BbsBls12381Sha256 from the zkryptium crate (Rust backend) and @noble/curves (TypeScript frontend).
Tally files are encrypted using ECIES with X25519:
- The host's password is stretched into a 32-byte seed via PBKDF2-HMAC-SHA256 (using a server-side salt and configurable iterations).
- The seed is interpreted directly as an X25519 static private key; the corresponding public key is stored in the meeting.
- At tally time the server generates an ephemeral X25519 keypair, performs ECDH with the meeting's public key, derives an encryption key via HKDF-SHA256, and encrypts the tally JSON with ChaCha20-Poly1305.
- The output file is:
ephemeral_pk (32 B) ‖ nonce (12 B) ‖ ciphertext+tag.
Because the private key is derived entirely from the password and the salt (never transmitted), the server can encrypt but never decrypt. Libraries used: x25519-dalek, chacha20poly1305, hkdf.
Rustsystem is the official voting system for F-sektionen at TLTH and is available at rosta.fsektionen.se.
This section covers how to run all parts of the system locally for development. You will find instructions for setting up the server and trustauth backends as well as building and running the frontend dev server. TODO!
Contact the person responsible for Rustsystem for instructions on how to deploy on the F-guild server.
Anyone can set up Rustsystem on their own server. This section covers how to configure the environment, build the Docker images, and run Rustsystem on a server you control. TODO!