Skip to content
Condorcet.vote Alpha
← Technical documentation

Signed ballot

What the voter’s browser signs, down to the byte, and how anyone verifies that signature from the published data alone.

A published ballot comes down to four values: the ring, the signed payload, the ring signature and the key image. They belong to the base protocol and verify without the blockchain, which only carries them. This page describes them field by field, with their encodings and the procedure for checking a signature.

Protocol v1

Anatomy

Four values per ballot.

A published ballot is a record of four values, the same on the register page and in the blockchain. None of them carries a date or an arrival order.

ring_id UUID v4, lowercase
The ring the ballot was signed against: its public keys, and only those, verify the signature.
payload string, at most 8,192 bytes
The signed payload, verbatim: a JSON document carried as an opaque string, never re-serialized.
signature blsag_…
The linkable ring signature over the exact payload bytes: 32 × (1 + n) bytes for a ring of n keys, i.e. 19,232 bytes at 600 keys.
key_image ki_<64 hex>_<8 hex>
The key image: one per key per election. Two ballots with the same image expose a reuse of the key, visible to everyone.
Published ballot · illustrative values
{
  "ring_id": "7d3f1c9a-2b4e-4f6a-8c1d-5e9b0a7f3c26",
  "key_image": "ki_a08a870cf43ebf3f62009c5add2194bc1626cb6b3aa2bd8a280635bd76d45861_d9f38b14",
  "signature": "blsag_5522c75bbd488b5e95bd2da407d66e20c83a909b86a1451465b73bbea8db6cac…_f5f947b9",
  "payload": "{\"v\":1,\"election\":\"0c2e7e2a-5df3-4c0e-9d2b-3f6a8f0c9e21\",\"ballot\":\"Alice > Bob = Charlie\",\"weight\":1}"
}

Illustrative values: the shapes are correct, but the checksums and the signature (truncated here) do not verify. In a votes block, each such record is one compact object on its own line of the ballots array; ballots are published there in batches of at least 600 (the remainder at close), sorted by key image. The ring digest belongs to the ring, not to the ballot: each ballot’s page shows it for the ring the ballot was signed in.

Signed payload

The envelope, byte for byte.

A flat JSON document, five fields at most, no additional property. The schema describes its generic shape; at submission, v, election, weight and, in non-anonymous voting, voter are additionally pinned to the values expected for the election and the voter.

v integer ≥ 1
The election’s protocol version (1 today); it must equal the version the election is permanently bound to.
election UUID v4, lowercase
The election identifier, which is also the election_id signing parameter. Never prefixed.
ballot UTF-8 string
The voter’s expression as a flat string, never nested JSON: a CEF ranking line or the /EMPTY_RANKING/ sentinel. Content not validated.
weight integer ≥ 1
Always present. Equal to the voter’s weight on the electoral roll; 1 unless weighted_voting is on.
voter UUID v4, lowercase, optional
Non-anonymous voting only: required and equal to the voter’s voter ID. Forbidden in an anonymous election.
Anonymous election
{"v":1,"election":"0c2e7e2a-5df3-4c0e-9d2b-3f6a8f0c9e21","ballot":"Alice > Bob = Charlie","weight":1}
Non-anonymous voting
{"v":1,"election":"0c2e7e2a-5df3-4c0e-9d2b-3f6a8f0c9e21","ballot":"Alice > Bob = Charlie","weight":1,"voter":"2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d"}
signed-payload.schema.json The envelope’s JSON Schema (draft 2020-12): the very file the server uses, served at the canonical URL of its $id. https://admin.stg.condorcet.vote/schemas/signed-payload.schema.json Download ↓
  • Built once, stored byte for byte. The browser produces the JSON string once; it is signed, transmitted and stored as is, in a binary column (bytea, never jsonb, which PostgreSQL would normalize). These bytes are the canonical ballot: verification, audit and tally all start from them.
  • No canonical form imposed. Key order and duplicate keys are neither normalized nor rejected: the signature covers the exact bytes. The official client emits compact JSON, keys in the order v, election, ballot, weight, voter, Unicode unescaped. The residual risk that two parsers read a pathological payload differently is documented and accepted.
  • At most 8,192 bytes. The cap counts bytes, because the signature covers bytes and labels may be multi-byte UTF-8. It covers the documented worst case: a complete ranking of 30 candidates with 64-character labels in 4-byte UTF-8, plus the envelope and the voter ID, about 7,836 bytes.
  • Envelope validated, content not. At submission the server validates the envelope: flat JSON, cap, schema, pinned values. The ballot content deliberately is not: a malformed expression becomes a null ballot at tally, still stored, published and verifiable.
  • ballot, one ranking line. In practice, one line in CEF ranking syntax, spaced form: Alice > Bob = Charlie. Tied candidates are sorted by Unicode code point, so that no ordering can serve as a covert mark; /EMPTY_RANKING/ expresses a blank vote. No CEF parameter, quantifier or tag: a multiplier (* or ^) makes the ballot null, it never inflates its weight.
  • Weight: signed, and authorized. weight is signed by the voter, so it cannot be altered afterwards, but the electoral roll is the authority: the server rejects any payload whose weight differs from the voter’s. One voter, one ballot, one weight.
  • voter: checked, not proven. In non-anonymous voting, the server checks that voter matches the session. Since the ring signature hides the signer, a third party cannot prove from the register alone that this ID is the signer’s: a documented limit.
  • A protocol version, no library version. v is the election’s protocol version, fixed forever. No version of the signing library or of the WebAssembly module is recorded or published anywhere: a per-ballot version would leak the chronology of the votes.
Signing and submission

Signed in the browser, verified on receipt.

Signing happens in the voter’s browser, with the CryptoVote library compiled to WebAssembly; only the payload, the signature, the key image and the ring ID are sent. The server verifies with the same Rust core, in its Extism build.

Browser side

  1. 1 The page receives the assigned ring (the list of its pk_ keys), the election ID and, in non-anonymous voting, the voter ID. It immediately computes the ring’s digest locally and displays it.
  2. 2 The ballot interface produces the ranking line; the browser builds the JSON payload, exactly once.
  3. 3 The secret key (distributed by the platform, or brought by the voter) signs: sign_vote_str_wasm(secret, payload, election_id, ring) returns the blsag_ signature and the ki_ key image.
  4. 4 The payload, the signature, the key image and the ring_id go unchanged to the voting page’s JSON API.
  5. 5 The secret key stays in the page’s memory until the voter leaves it, long enough to build the receipt once the ballot is accepted. The voting page enforces the strictest content security policy: no third-party script.

Server side, in this order

  1. 1 The election is open.
  2. 2 The voter has not voted yet: this participation-register check is the primary double-vote guard.
  3. 3 The envelope is valid: at most 8,192 bytes, flat JSON, schema-conformant, with v, election, weight and voter equal to the expected values.
  4. 4 The named ring belongs to the election, and the key image has never been used in it.
  5. 5 The signature verifies against this ring’s keys (about 0.5 seconds at 600 keys).
  6. 6 One atomic transaction marks the voter as having voted, records the ring they voted in and inserts the ballot, bytes intact, with no timestamp; a uniqueness constraint on (election, key image) settles simultaneous submissions.

Submission goes through the voting link’s session: at that instant the platform sees which voter submits which ballot. The protocol trusts it at vote time, then it forgets that link: no data ties an identified voter to their stored ballot, and everything it publishes verifies without it. An accepted ballot is final.

Ring signature

bLSAG, with election-scoped key images.

A ring signature proves that a member of the ring signed, without saying which one. This one is linkable: two signatures by the same key in the same election recognize each other.

  • The scheme. An experimental variant of bLSAG (Back’s Linkable Spontaneous Anonymous Group, the LSAG construction of Liu, Wei and Wong, as used by Monero), whose only difference is the election-scoped key image.
  • Schnorr-based, but not Schnorr. bLSAG generalizes the Schnorr challenge–response over a ring: the signer’s response, s = α − c·x, is chained around all members, so the signature verifies for the whole ring without pointing at its author.
  • The primitives. Ristretto255 curve (curve25519-dalek), Blake2b-512 hash. A secret key is a 32-byte scalar; the public key is derived from it: pk = sk·G.
  • What the signature binds. The exact payload bytes, the election ID (election_id, NFC-normalized by the library, a no-op on an ASCII UUID) and the ring’s member set. Change one byte, the election or the ring, and verification fails.
  • Ring order does not matter. The library sorts the ring internally, on both sides: only the set of keys counts. The platform publishes it in ascending order anyway.
  • Frozen per ring. A ring’s member set is identical from its first signature onward, forever. Creating an additional ring during the vote (an emergency ring) invalidates nothing.
  • Anonymity is the ring. The verifier cannot tell which member signed: the cryptographic anonymity set is the ring. The practical set is smaller, the ring’s voters who actually voted; padding keys add no anonymity.
  • Status, honestly. The CryptoVote library is experimental and has not been audited; its election-scoped key-image variant does not come from a published paper. Its code is open (AGPL-3.0) and auditable; an independent review is planned before any high-stakes use.
Key image

One per key, per election.

The key image comes with every signature. It does not point at the signer within the ring, but it is the same every time the same key signs in the same election: that is what makes a reuse visible to everyone.

Definition · x secret key, G generator, H_p hash to the curve
P   = x · G
I_e = x · H_p(domain ‖ len(election_id) ‖ election_id ‖ P)
  • Election-scoped. The H_p base includes the election ID: one key yields the same image across the whole election, all rings included, and unrelated images from one election to the next. This is the only difference from standard bLSAG, where I = x · H_p(P).
  • Uniqueness enforced. At submission, a uniqueness constraint on (election, key image) rejects any second use. A verifier checks the same across all votes blocks: each ki_ appears only once.
  • Sort key. Ballots are sorted by key image, on the register as in every votes block: an order unrelated to when the votes arrived.
  • What it does not detect. It only links uses of the same secret key. A voter holding two keys (two distributions, or one distribution and a brought key) would produce two different images: the primary guard remains the participation register, checked before the signature, with the public bound “ballots ≤ voters who voted”.
  • A lookup key for the voter. With their receipt, a voter finds their ballot by its key image, even before batch publication: the register’s targeted lookup accepts this high-entropy, unguessable value and allows no enumeration.
  • Proving it is theirs. If they choose to, voters can prove a key image is theirs without revealing their secret key (Operation D, the vote proof). The proof is transferable and reveals the vote: the voter’s choice, never the organizer’s.
Ring digest

Check the ring you were served.

A ballot’s anonymity is exactly the ring it was signed under. A platform serving one voter a ring that differs by a single key could later tell their ballot apart. The digest makes that manoeuvre detectable.

  • The format. ring_<64 hex>_<8 hex>: the canonical, order-independent fingerprint of the member set, computed by CryptoVote (version 1.0 and later). It refuses what signing would refuse: fewer than two members, a duplicate or malformed key.
  • Computed once, published everywhere. The server computes it in the transaction that creates the ring and never recomputes it: the ring is frozen, so is its digest. It appears on the register (the ring’s card and every ballot’s page), in the rings block and in the ring’s key download.
  • Recomputed by the voter. The voting page computes the digest from the ring it received, shows it in the confirmation and the summary, and writes it into the receipt (ring_digest). The server never sends it a digest to compare against: a value served with the ring could be tailored with it.
  • Compared in public. The comparison that matters is the voter’s, between their digest and the value published for everyone. A mismatch means they signed under a ring other than the published one.
  • What it guarantees. Detection, not prevention. The digest derives from the published keys: it adds no cryptographic guarantee, only a practical point of comparison. A verifier recomputes it from public_keys.
  • The same computation on both sides. The server (Extism) and the browser (WebAssembly) run the same Rust core; a shared reference fixture, read by both test suites, pins that they derive the same digest across upgrades.
Encodings

Prefixed strings, with a checksum.

Every cryptographic artifact travels as <tag>_<hex body>_<checksum>. The display does not lie: the prefix and the checksum are part of the real encoding, and the value is copied as is.

pk_ 32 bytes · 64 hex
Public key, a ring member. Published with the rings.
sk_ 32 bytes · 64 hex
Secret key. Never published: it only appears in the files the voter keeps.
ki_ 32 bytes · 64 hex
A ballot’s key image.
blsag_ 32 × (1 + n) bytes
Ring signature, for a ring of n keys.
ring_ 32 bytes · 64 hex
Ring digest.
nonce_ 32 bytes · 64 hex
Nonce of a vote proof (Operation D).
own_ 64 bytes · 128 hex
Proof of ownership of a key image (Operation D).
Reading a value · illustrative value
pk_6eff732bec0d28b8…11abc91fdc1802cc_480cc89f
│  │                                 │
│  │                                 └ checksum: 4 bytes of BLAKE3, 8 hex
│  └ body: lowercase hex, whole bytes
└ tag: pk, sk, ki, blsag, ring, nonce, own
  • A checksum over the tag. A 4-byte BLAKE3 digest with the tag folded in: a typo, a truncated value or a pk_ pasted where a ki_ is expected is caught at once.
  • A guard, not security. Anyone can compute a valid checksum for any bytes. Authenticity comes solely from the bLSAG proof.
  • Bare hex rejected. CryptoVote’s WebAssembly, Extism and command-line interfaces, like the site’s own libraries, accept the prefixed form only; bare hex is reserved for the library’s Rust API.
  • Outside the signed bytes. Prefixes wrap keys, key image and signature, never the payload: its election field stays a bare UUID v4, and ballot is untouched. A ballot’s identifier on the register is an application UUID, not a CryptoVote artifact.
Verification

Check a ballot against its ring.

Six steps, from the published data alone, without asking the platform for anything. They apply equally to a ballot read on the register and to one read in a votes block.

  1. 1 Fetch the ballot’s record: ring_id, payload, signature, key_image (the ballot’s page on the register, or an entry of a votes block’s ballots array).
  2. 2 Fetch the keys of ring ring_id: the register’s JSON download (election_id, ring_id, ring_number, ring_digest, public_keys) or the matching entry of a rings block.
  3. 3 Recompute the digest from public_keys and compare it with the published ring_digest; a voter also compares it with the one in their receipt.
  4. 4 Validate the payload against the signed-payload schema: election equal to the election ID, v equal to its protocol version, voter present if and only if voting is non-anonymous. weight cross-checks against the weights of the closure block’s electoral roll.
  5. 5 Verify the signature over the exact payload bytes, with the election ID as election_id, the key image and the ring: the result is valid or invalid, nothing else.
  6. 6 Check that the key image appears only once in the whole election.
With the cryptovote command line · illustrative values
$ cryptovote ring-digest --ring ring.txt
digest=ring_bacc6d4ec64d44fc13c49ff6f6316e73a77554c3ff440fff55d62dc3e8ae1838_3c527fe4

$ cryptovote verify --vote "$PAYLOAD" \
    --election-id 0c2e7e2a-5df3-4c0e-9d2b-3f6a8f0c9e21 \
    --signature blsag_5522c75b…_f5f947b9 \
    --key-image ki_a08a870cf43ebf3f62009c5add2194bc1626cb6b3aa2bd8a280635bd76d45861_d9f38b14 \
    --ring ring.txt
valid

ring.txt holds one pk_ key per line, taken from public_keys. $PAYLOAD is the value of the payload field, that is the decoded JSON string, with no re-serialization: a different space, key order or escape changes the bytes and makes verification fail. Exit code: 0 valid, 1 invalid, 2 bad input. The same operations exist in the @condorcet.vote/crypto-vote WebAssembly module (verify_vote_str_wasm, ring_digest_wasm) and in the library’s Extism build.

What a valid signature proves. That a holder of one of this ring’s keys signed exactly these bytes, for this election, under this key image. It does not say which one, does not prove that the voter field names the signer (that is a server-side check), and does not judge the ballot content, classified as expressed, blank or null at tally.

The full verification procedure