Skip to content

CondorcetVote/cryptoVote

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

44 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

crypto_vote

Warning

Experimental, unaudited, AI-assisted code. Do not deploy in a real (important, critical) election without an independent cryptographic review.

  • No professional security audit has been performed. The implementation, its test surface and its threat model have only been reviewed by the author and AI assistants.
  • The election-scoped key-image variant is not from a published paper. Standard BLSAG defines the linking tag as I = x · H_p(P); this crate uses I_e = x · H_p(domain ‖ election_id ‖ P) so the same identity is unlinkable across separate elections. The construction is a small, intuitive modification of BLSAG, but it has not been formally analysed in the cryptographic literature (as of writing). Treat it as a research prototype.

The crate is therefore suitable for prototyping, research, educational use and internal CTF-style exercises. It is not ready to back a critical real-world ballot.

A pure-Rust cryptographic oracle for verifiable, anonymous, double-vote-resistant ballots.

The crate is a minimal building block that offers a small set of operations and refuses to take part in anything else.

# Operation Where it runs Function
A Generate identity Either side generate_identity()
B Sign a ballot Voter's device (WebAssembly in the browser) sign_vote(secret, vote, election_id, ring)
C Validate a proof Host / server verify_vote(vote, election_id, signature, key_image, ring)
D Prove ownership of a key image Voter's device (prove) / anyone (verify) prove_ownership(secret, election_id, context) / verify_ownership(public, key_image, election_id, context, proof)

Operations A–C are the core anonymous-voting flow. Operation D is optional and opt-in — it is the deliberate inverse of the ring signature's anonymity. See Proving ownership of a vote.

Cryptographic choices

  • Curve: Ristretto255 (curve25519-dalek). Prime-order, constant-time, pure Rust.
  • Ring signature scheme: an experimental BLSAG (Back's Linkable Spontaneous Anonymous Group) variant implemented locally from the LSAG/BLSAG equations, with election-scoped key images.
  • Hash: Blake2b-512 (blake2 crate). Natively 64-byte output, fed directly into Scalar::from_hash.
  • CSPRNG: SysRng, which on wasm32 delegates to Crypto.getRandomValues through the getrandom crate's wasm_js feature.

Anti-double-vote contract

The module never tells the host whether a voter has already voted. That decision belongs to the host. The protocol gives the host one deterministic identifier per (secret key, election_id) pair — the key image — which it must store and de-duplicate against:

  1. Retrieve the key image returned by sign_vote.
  2. Look it up in the host's storage.
  3. Reject the transaction if it already exists.
  4. Otherwise ask verify_vote whether the proof is mathematically valid, and on success store the key image.

Election binding

The key image is a function of both the secret key and the election_id. The same voter using the same secret key twice in one election produces the same tag, so double voting is detectable; the same voter using that key in another election produces a different tag, so public proofs are not linkable across elections just by comparing key images.

Every call to sign_vote and verify_vote also mixes the same election_id byte string into the BLSAG challenge chain. The host should pass a stable per-election identifier (UUID, slug, hash of the event configuration, …) on both sides.

This gives two election-context checks:

  • The key image is itself scoped by election_id.
  • The signature itself only validates against the election_id it was produced with.

The library normalises election_id to Unicode NFC before hashing, on both the signing and the verification side. Callers can therefore pass the same logical identifier in any Unicode form (NFC, NFD, mixed) without breaking verification — the typical case where a server stores the ID in one normalisation and the voter's page receives it in another no longer silently invalidates every ballot. ASCII identifiers are NFC by definition, so UUIDs and slugs are unaffected.

Ring size and the anonymity set

The cryptography guarantees the verifier cannot tell which member of the ring produced a given signature — but the anonymity set is exactly the ring. A ring of size n gives a 1/n chance of guessing the signer uniformly at random, and no more:

  • n = 2: the protocol still validates, but the "anonymity" is binary — every ballot leaks down to "voter A or voter B". The library accepts it because cryptographically it is sound, not because it is privacy-meaningful. Treat it as a debugging configuration, not a production one.
  • n < 8: real-world side-channels (registration order, login timing, IP correlation on the host) usually let an observer narrow the set further. Treat anything below 8 as practically de-anonymising.
  • n ≥ 16: a reasonable floor for a real ballot. Larger rings cost more (O(n) for both signing and verification, plus signature size of 32 * (1 + n) bytes), so pick the largest ring your latency budget allows.

The minimal n = 2 floor is enforced inside the library; picking a useful n is the host's responsibility.

Ring lifecycle: freeze before voting opens

The full set of authorised public keys (the "ring") is mixed into the hash chain of every signature, the same way election_id and the ballot bytes are. As a consequence, the ring must be frozen before the first ballot is cast and stay composition-identical for the whole duration of the election. In practice:

  • All voter identities must be generated and registered before voting opens. The natural moment is during the election's enrolment window, which closes when voting opens.
  • Once voting opens, the host serves the same ring to every voter and uses that same ring at verify_vote time. Adding, removing, or swapping a member instantly invalidates every signature produced under the previous ring — there is no migration path.
  • Ring order does not matter (both sides canonicalise it internally by sorting lexicographically on the compressed point encoding), so the host is free to return it in any order over the wire. Only the set of members matters.
  • If a voter is added later, treat it as a new election: new election_id, fresh ring, fresh key-image store. Ballots from the old election remain verifiable as long as the host keeps a snapshot of the old ring.

For the same reason, the host should persist the ring it used to verify each ballot (or at least the election's frozen ring) alongside the ballot itself, so audits later on can rerun verify_vote deterministically.

Proving ownership of a vote (Operation D)

The ring signature deliberately hides which authorised voter cast a ballot. Operation D is the opt-in inverse: it lets the holder of a secret key prove to a third party that a given key image — and therefore the ballot next to it in the public registry — is theirs, without revealing the secret key.

The intended use case is mandated / proxy voting: a voter (or a mandate-holder voting on someone's behalf) must be able to demonstrate, after the fact, how a ballot was cast.

use crypto_vote::{
    generate_identity, sign_vote, generate_nonce, prove_ownership, verify_ownership,
};

let voter   = generate_identity();
let other   = generate_identity();
let ring    = vec![voter.public_key, other.public_key];
let election_id = "550e8400-e29b-41d4-a716-446655440000";

// The voter casts a ballot as usual.
let vote = sign_vote(&voter.secret_key, b"option-A", election_id, &ring).unwrap();

// Later, a verifier (possibly external to the election) generates a fresh
// nonce and sends it to the voter, who proves the registry's key image is
// theirs. `context` is opaque bytes — here, the nonce's raw bytes.
let nonce = generate_nonce();
let proof = prove_ownership(&voter.secret_key, election_id, nonce.as_bytes());

// The verifier checks it with public data only — the voter's public key,
// the key image from the registry, the election id and the same nonce.
assert!(verify_ownership(
    &voter.public_key,
    &vote.key_image,
    election_id,
    nonce.as_bytes(),
    &proof,
));

What it is

A non-interactive Chaum–Pedersen proof of equality of discrete logarithms. The key image is I = x·B, where B = H_p(election_id || P) is the same election-scoped base the key image was built from and P = x·G is the voter's public key. The proof demonstrates knowledge of a single scalar x satisfying both P = x·G and I = x·B — which only the true owner of I knows — while revealing nothing else about x. On the wire it is 64 bytes (own_<128 hex>_<8 hex checksum> in the prefixed format), independent of the ring size.

The verifier can be anyone, with their own nonce

Verification needs only public data: the voter's public key, the key image, the election_id and the proof. No cooperation from the organisers and no secret are required, so a party completely external to the election can check a proof on its own.

Everything the verifier wants the proof bound to goes into context. The recommended pattern is a verifier-chosen nonce:

  1. the verifier picks a fresh random nonce and sends it to the prover;
  2. the prover calls prove_ownership with context = nonce (the service may also append the ballot bytes);
  3. the verifier checks with the same context.

The nonce is folded into the proof's challenge, so the prover could not have precomputed it — the proof is fresh and cannot be replayed.

generate_nonce() is provided as a verifier-side convenience: it returns a Nonce (32 fresh bytes) from the same platform CSPRNG (SysRng; Web Crypto in the browser). The organisation requesting the proof calls it, sends the nonce to the prover, and both pass its bytes as context. Using it is optional — any unpredictable, single-use value works.

Like every other public value, a Nonce has the full encoding surface, including the self-describing, checksum-protected prefixed form (nonce_<hex>_<checksum>). On the high-level bindings the nonce travels exclusively in that form (validated on the way in), exactly like keys, signatures and the proof itself — the prefix is transport only, the proof binds the nonce's raw bytes. The pure-Rust API stays fully general: it takes opaque context: &[u8], so a richer context (e.g. the nonce with the ballot bytes appended) is always available there.

Properties and limits

  • No secret key is revealed. The proof is zero-knowledge for x.
  • Sound. A third party cannot prove ownership of a key image that is not theirs (they would have to know the matching secret scalar).
  • De-anonymising by design. Producing a proof intentionally ties P ↔ I; it is the opt-in opposite of the ring signature's anonymity.
  • Transferable, not designated-verifier. A Chaum–Pedersen proof is publicly checkable, so the nonce gives freshness but not non-transferability: whoever holds (context, proof) can convince anyone else too. For ordinary mandate scenarios this is fine. If you need a proof that convinces only the designated verifier, you need a different (designated-verifier) construction — this crate does not provide one.
  • It does not consult any registry. verify_ownership answers exactly "does the holder of this public key vouch for this key image, under this election and context?" Tying the key image to a specific ballot is the registry's job (the vote signature binds ballot + key image, and the host de-duplicates on the key image); the caller does that lookup.
  • Coercion note. This makes the "prove how I voted" capability concrete and transferable. It is the right tool for mandated voting, but by the same token it means a coercer who can compel a proof (or the secret key) can learn how someone voted. This is an inherent property of verifiable receipts, not a flaw in the proof.

From JavaScript / Extism

Every value crossing these boundaries — including the nonce and the proof — uses the prefixed, checksum-protected form (nonce_…, own_…, pk_…, ki_…), validated on input.

  • wasm-bindgen: generate_nonce_wasm() returns a fresh prefixed nonce string (nonce_…); prove_ownership_wasm(secret, electionId, nonce) returns the prefixed own_… proof; verify_ownership_wasm(publicKey, keyImage, electionId, nonce, proof) returns a bool.
  • Extism: generate_nonce takes no input and returns {"nonce": nonce_…}; prove_ownership takes {"secret": sk_…, "election_id": str, "nonce": nonce_…} and returns {"proof": own_…}; verify_ownership takes {"public": pk_…, "key_image": ki_…, "election_id": str, "nonce": nonce_…, "proof": own_…} and returns {"valid": bool}.

For a custom or binary context (e.g. nonce + ballot bytes), use the pure-Rust API, which takes opaque context: &[u8].

Build matrix

The release workflow produces eight artefacts; the same commands work locally. Pick the triple/flavour that matches your need, install its one-off prerequisite, then run the matching build command. Edition 2024 implies Rust ≥ 1.85; CI tracks stable.

Triple — flavour Build host One-off setup Output
x86_64-unknown-linux-gnu Linux x64 system C linker (any dev box has one) dynamic ELF, ~700 KB
x86_64-unknown-linux-musl Linux x64 musl-tools (provides musl-gcc) static ELF, ~800 KB
aarch64-unknown-linux-gnu Linux x64 or arm gcc-aarch64-linux-gnu cross-toolchain dynamic ELF
aarch64-unknown-linux-musl Linux x64 or arm none — uses rust-lld static ELF
riscv64gc-unknown-linux-gnu Linux x64 or arm gcc-riscv64-linux-gnu cross-toolchain dynamic ELF
aarch64-apple-darwin macOS arm (M-series) Xcode Command Line Tools Mach-O
wasm32-unknown-unknown — wasm-bindgen any cargo install wasm-pack ES-module bundle for browsers
wasm32-wasip1Extism plugin any none — uses rust-lld single .wasm for every Extism host SDK

Install commands below are Debian/Ubuntu (apt); adapt to your distribution (dnf, pacman, zypper, brew, …) or use cross for a Docker-based path that needs nothing on the host. CI runs on ubuntu-latest, which is why the upstream pipeline uses apt.

Build commands

Standard template — works as-is for x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl (after apt install musl-tools) and aarch64-apple-darwin (on a macOS host):

cargo build --release --locked --target <TRIPLE> --bin cryptovote
# → target/<TRIPLE>/release/cryptovote

Three cross-Linux rows need a linker selector — set it in the environment, then run the same command:

# aarch64-unknown-linux-gnu  (after `apt install gcc-aarch64-linux-gnu`)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc

# aarch64-unknown-linux-musl  (no apt package needed)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld

# riscv64gc-unknown-linux-gnu  (after `apt install gcc-riscv64-linux-gnu`)
export CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER=riscv64-linux-gnu-gcc

Browser WASM uses wasm-pack (which wraps cargo build and emits JS glue alongside the .wasm). The wasm-release profile is the WebAssembly-tuned variant defined in Cargo.toml: same size knobs as release, plus panic = "abort" (see Cargo.toml's [profile.release] comment for why panic strategy is kept out of the default release profile):

wasm-pack build --profile wasm-release --target web --out-dir pkg-browser \
    -- --no-default-features --features wasm --locked
# → pkg-browser/{crypto_vote.js, crypto_vote_bg.wasm, …}

--target web emits a self-contained ES module you can import from a static page, a bundler (Vite, Rollup, esbuild), Bun, or Node — it instantiates the WASM itself via an async init(), so no bundler plugin is required anywhere. This is the build published to npm. --no-default-features drops clap; --features wasm enables wasm-bindgen, js-sys, and the getrandom/wasm_js backend so randomness comes from Crypto.getRandomValues.

Server-side WASM uses the Extism flavour — the same .wasm is then loadable by every Extism host SDK (browser, Node, Python, Go, Rust, …). getrandom autoselects WASI's random_get on this target, so no extra backend wiring is needed:

cargo build --profile wasm-release --locked --target wasm32-wasip1 --lib --no-default-features --features extism
# → target/wasm32-wasip1/wasm-release/crypto_vote.wasm   (~360 KB)

See Extism plugin for the host-side usage from JS, Node, and other Extism SDKs.

Using the library

use crypto_vote::{generate_identity, sign_vote, verify_vote};

let alice   = generate_identity();
let bob     = generate_identity();
let charlie = generate_identity();

let ring        = vec![alice.public_key, bob.public_key, charlie.public_key];
let election_id = "550e8400-e29b-41d4-a716-446655440000"; // any stable string (UUID, slug, …)

// Bob signs "option-A" — `bob.secret_key` never leaves Bob's device.
let proof = sign_vote(&bob.secret_key, b"option-A", election_id, &ring).unwrap();

// The host: first dedup `proof.key_image`, then verify.
assert!(verify_vote(
    b"option-A",
    election_id,
    &proof.signature,
    &proof.key_image,
    &ring,
));

Encoding formats

Every value the API exchanges as text — public keys, secret keys, key images, signatures — has two interchangeable string encodings. They carry the exact same bytes; nothing about the cryptography changes between them.

Format Example Methods
Bare hex e2f2ae0a…2d76 to_hex() / from_hex(..)
Prefixed pk_e2f2ae0a…2d76_bfb1c73d to_prefixed() / from_prefixed(..)

The prefixed format wraps the same lowercase hex body with two conveniences:

  pk_e2f2ae0a…e08d2d76_bfb1c73d
  │  │                 │
  │  │                 └ checksum: 4 bytes (8 hex chars)
  │  └ body: identical to to_hex()
  └ tag: pk | sk | ki | blsag
  • a tag up front (pk_, sk_, ki_, blsag_) says what kind of value it is, so a public key pasted where a key image was expected is caught immediately instead of failing deep in verification;
  • a checksum at the end (a 4-byte BLAKE3 digest, hex-encoded) catches the overwhelming majority of single-character typos, transpositions and truncated pastes. The tag is folded into the checksum, so relabelling a pk_… value as ki_… is also detected.

The checksum is an integrity / typo guard only — never a security primitive. Anyone can compute a valid checksum for any bytes; authenticity comes solely from the BLSAG proof.

Type Tag from_prefixed signature
PublicKey pk_ PublicKey::from_prefixed(s)
SecretKey sk_ SecretKey::from_prefixed(s)
KeyImage ki_ KeyImage::from_prefixed(s)
Signature blsag_ Signature::from_prefixed(s, ring_size)
let id = crypto_vote::generate_identity();
let pretty = id.public_key.to_prefixed();        // "pk_…_…"
assert!(pretty.starts_with("pk_"));
let back = crypto_vote::PublicKey::from_prefixed(&pretty).unwrap();
assert_eq!(back, id.public_key);

// Wrong tag is rejected even though the bytes would decode fine as hex:
assert!(crypto_vote::KeyImage::from_prefixed(&pretty).is_err());

from_prefixed returns Error::InvalidPrefix (missing/wrong tag) or Error::InvalidChecksum (mistyped or corrupted value), in addition to the usual length / encoding errors.

The WASM, Extism and CLI front ends speak the prefixed format exclusively — it is all they emit and all they accept; bare hex is rejected at those boundaries. The bare-hex to_hex/from_hex pair is reserved for the pure-Rust library API, where the caller is trusted to know what it is decoding. This keeps every value that crosses a high-level boundary self-describing and checksum-protected.

Private key format and properties

A secret key is a uniformly random scalar in the Ristretto255 scalar field — mathematically an integer in [1, ℓ), where ℓ is the prime order of the group (ℓ = 2^252 + 27742317777372353535851937790883648493, ≈ 2²⁵². So roughly 2²⁵² distinct keys exist). The crate ships two interchangeable encodings; pick the one that matches your transport:

Encoding Shape Where it's used
Raw bytes [u8; 32] little-endian SecretKey::{to,from}_bytes, sign_vote_*_wasm and is_valid_secret_key_wasm secret inputs
Hex 64 lowercase characters SecretKey::{to,from}_hex — pure-Rust library API only
Prefixed sk_<64 hex>_<8 hex> SecretKey::{to,from}_prefixed, Extism plugin, CLI (see Encoding formats)

All three encodings carry the same value bit-for-bit. The prefixed form is the only one the Extism and CLI front ends accept (bare hex is rejected there — it is reserved for the pure-Rust library API); raw bytes is the only format the wasm-bindgen flavour accepts for secret material, because a Uint8Array is mutable and the JS caller can wipe it with .fill(0) — a JS String (and therefore a hex string) cannot be erased from the heap on demand.

Validity rules

Every parser (SecretKey::from_bytes, from_hex, and the WASM / Extism entry points) enforces three checks. A key that fails any of them is rejected, never silently coerced:

  1. Length. Exactly 32 decoded bytes — Error::InvalidLength. Hex input must therefore be 64 characters.
  2. Canonical encoding modulo ℓ. The 32 bytes must decode to a scalar in [0, ℓ)Error::InvalidScalar. The library refuses any "non-reduced" encoding (e.g. a value ≥ ℓ but < 2²⁵⁶), because that would let two different byte strings designate the same key — a classic malleability footgun.
  3. Non-zero. The zero scalar is rejected — Error::InvalidSecretKey. Its derived public key would be the Ristretto identity point, which the protocol cannot use as a participant (and which PublicKey independently refuses with Error::InvalidIdentityPoint).

Cryptographic properties

  • The public key is fully determined by the secret key. Internally public_key = secret_key · G where G is the Ristretto255 base point. Round-tripping a secret through to_bytesfrom_bytes derives the same public key, so you only need to persist the 32-byte secret — the public key (and therefore the ring entry) can always be re-derived. There is no separate key-schedule, clamping, or hashing step applied to the secret.
  • Same key, same ring, same election ⇒ same key image. This is what makes double-vote detection possible. The key image is I_e = x · H_p(domain ‖ election_id ‖ x · G), so it is fully deterministic in (secret_key, election_id) and changes between elections (see Election binding).
  • Entropy. generate_identity samples from SysRnggetrandom(2) / getentropy(2) / BCryptGenRandom natively, Crypto.getRandomValues in the browser. Any uniform value in [1, ℓ) is a valid secret. Never derive a secret from a user password without a strong KDF (Argon2id / scrypt) producing 32 uniform bytes — the protocol's anonymity rests on the secret being unguessable.
  • Memory hygiene. Inside Rust the scalar lives in a SecretKey whose Drop impl calls Zeroize. Every transient buffer that touches the secret on the WASM / Extism boundary is wrapped in Zeroizing. The JS-side Uint8Array is the caller's responsibility — see Operation A for the wipe pattern. The hex form gives up that last step (a JS String is immutable), which is why the wasm-bindgen flavour keeps the secret as bytes.

Validating a secret key without constructing one

Reading a stored secret back and want a quick "is this still a usable key?" check before doing anything else? Two zero-cost helpers apply the three rules above and return a plain bool:

use crypto_vote::SecretKey;

let bytes: [u8; 32] = load_from_disk();
if !SecretKey::is_valid_bytes(&bytes) {
    return Err("stored secret is corrupted");
}

assert!(SecretKey::is_valid_hex("aabbccdd…"));        // canonical, non-zero
assert!(!SecretKey::is_valid_hex("not hex"));          // bad encoding
assert!(!SecretKey::is_valid_hex(&"00".repeat(32)));   // zero scalar

// Same check for the prefixed form (tag + checksum must also be valid):
assert!(SecretKey::is_valid_prefixed("sk_aabbccdd…_1a2b3c4d"));

These are the same checks performed implicitly at the start of every sign_vote call, so calling them up-front is purely a convenience — useful for surfacing a clear UI message before the user has filled in the rest of the form. The WASM and Extism layers expose the same helper (see below).

Using the command line

# Generate an identity (one per voter). Output uses the prefixed format.
$ cryptovote keygen
secret=sk_…_…
public=pk_…_…

# Sign a ballot. `ring.txt` is one prefixed public key (`pk_…`) per
# line. Keep the secret in a file or pass `--secret -` to read it from
# stdin.
$ cryptovote sign --secret-file secret.key --vote "option-A" \
    --election-id "election-2026-05" --ring ring.txt
signature=blsag_…_…
key_image=ki_…_…

# Verify. Exit code 0 = valid, 1 = invalid, 2 = bad input. The
# --signature / --key-image values are prefixed (`blsag_…`, `ki_…`).
$ cryptovote verify --vote "option-A" --election-id "election-2026-05" \
    --signature blsag_…_… --key-image ki_…_… --ring ring.txt
valid

Every key / signature argument is the prefixed form (pk_…, sk_…, ki_…, blsag_…); bare hex is rejected. The CLI always prints the prefixed form.

Vote payload format

The library treats the vote as opaque bytessign_vote and verify_vote both take &[u8], with no size limit and no parsing. JSON, Protobuf, raw text, or arbitrary binary all work the same way. The WASM and Extism bindings expose two entry points per operation to cover this: a text one (vote as a UTF-8 string) and a binary one (vote as a Uint8Array in WASM, hex-encoded in Extism). Both funnel into the same &[u8] core, so a ballot signed through one is verifiable through any of them as long as the bytes match. See those sections below.

The contract for the host: what you sign is what you store is what you verify, byte-for-byte. If the host re-encodes the payload between receiving it and verifying it — JSON re-serialisation, BOM stripping, line-ending normalisation, Unicode normalisation, lowercasing, anything — verification will fail, because Blake2b is sensitive to every single byte. The safe rule is: persist the raw bytes received from the voter, and hand those same bytes back to verify_vote.

Note that the vote payload is treated as raw bytes and is never normalised by the library — unlike election_id, which is forced to NFC. The asymmetry is deliberate: the election ID is a label the host controls and re-emits across encodings, so normalising it makes the API robust; the vote is content the host stores verbatim, so normalising it would silently change what was signed.

Large or structured payloads in the browser

There are two signing entry points. Pick by ballot type:

  • sign_vote_str_wasm(secret, voteStr, electionId, ring)vote is a string. Use it for text ballots (a label, a stringified JSON ballot). Most common.
  • sign_vote_bytes_wasm(secret, voteBytes, electionId, ring)vote is a Uint8Array. Use it for binary ballots (raw Protobuf, any non-UTF-8 bytes).

The natural pattern for a JSON ballot is to serialise it once on the voter's device and pass that exact string everywhere afterwards:

const ballot     = JSON.stringify(form);                   // a string
const electionId = "550e8400-e29b-41d4-a716-446655440000"; // plain JS string
const [sigHex, tagHex] = sign_vote_str_wasm(
    secretBytes, ballot, electionId, ringHex,
);
// `secretBytes` is the `Uint8Array` returned by `generate_identity_wasm`
// (or read back from your store). Wipe it with `secretBytes.fill(0)`
// as soon as you no longer need it in memory.

// Send `ballot` (the same string), `sigHex` and `tagHex` to the host.
// The host stores `ballot` verbatim — it must never JSON.parse +
// JSON.stringify the payload, or the re-serialised string may differ
// byte-for-byte and verification will fail.

// Binary ballot? Sign the exact bytes and store them verbatim instead:
//   const [sigHex, tagHex] = sign_vote_bytes_wasm(
//       secretBytes, ballotBytes /* Uint8Array */, electionId, ringHex,
//   );

Large or structured payloads on the CLI

The --vote flag accepts - to read the ballot from standard input. Use it for anything that exceeds your shell's argv limit, contains newlines, or is binary:

cat ballot.json | cryptovote sign \
  --secret-file secret.hex --vote - --election-id "election-2026-05" --ring ring.txt

cat ballot.json | cryptovote verify --vote - \
    --election-id "election-2026-05" \
    --signature <hex> --key-image <hex> --ring ring.txt

Using from JavaScript / WebAssembly

The library exposes a thin wasm-bindgen layer behind the wasm feature. The npm package and a self-built bundle are the same --target web artefact: a self-contained ES module that instantiates the WASM itself through an async init() default export. It works the same everywhere — a bundler (Vite, Rollup, esbuild), Bun, Node, or a plain <script type="module"> — with no bundler plugin required. You call await init() exactly once.

Install from npm

npm install @condorcet.vote/crypto-vote

The package is published to npmjs.com on every release:

import init, {
    generate_identity_wasm,
    derive_public_key_wasm,
    sign_vote_str_wasm,    // text ballot
    sign_vote_bytes_wasm,  // binary ballot (Uint8Array)
    verify_vote_str_wasm,
    verify_vote_bytes_wasm,
    is_valid_secret_key_wasm,
    secret_key_from_prefixed_wasm,      // import sk_… string → bytes
    secret_key_to_prefixed_wasm,        // export bytes → sk_… string
    is_valid_prefixed_secret_key_wasm,  // validate an sk_… string
} from "@condorcet.vote/crypto-vote";

// Instantiate the WASM once. A bundler resolves the .wasm asset for you;
// no plugin needed. Then every function is ready to call.
await init();
const [secretBytes, publicKey] = generate_identity_wasm();

Why --target web and not --target bundler? The bundler target relies on the non-standard WebAssembly/ESM-integration that only webpack implements, so it breaks under Vite (needs vite-plugin-wasm) and Bun (treats the .wasm as a static asset). --target web is the portable choice.

Build from source

See the Build matrix above for the full command and flag breakdown. The short version, run from the crate root:

wasm-pack build --profile wasm-release --target web -- --no-default-features --features wasm

The resulting pkg/ directory contains the .wasm artefact and the JS glue — the same files the npm package ships. Copy it next to your page or import it through your bundler.

Initialise the module

await the default export exactly once before calling anything else; it streams and instantiates the .wasm file. This applies to both the npm package and a self-built bundle:

import init, {
    generate_identity_wasm,
    derive_public_key_wasm,
    sign_vote_str_wasm,    // text ballot
    sign_vote_bytes_wasm,  // binary ballot (Uint8Array)
    verify_vote_str_wasm,
    verify_vote_bytes_wasm,
    is_valid_secret_key_wasm,
    secret_key_from_prefixed_wasm,
    secret_key_to_prefixed_wasm,
    is_valid_prefixed_secret_key_wasm,
} from "@condorcet.vote/crypto-vote"; // or "./pkg/crypto_vote.js" when self-built

await init();

Operation A — generate an identity

// The secret comes back as a `Uint8Array` (32 raw bytes); the public
// key as a prefixed string ("pk_…_…"). The asymmetry is deliberate: a JS
// string is immutable, so once a secret has been turned into one it
// cannot be erased from the JS heap until the GC eventually collects it.
// A `Uint8Array` is a mutable buffer the caller can wipe explicitly with
// `.fill(0)` once the secret has been used or persisted.
const [secretBytes, publicKey] = generate_identity_wasm();

// 1. Persist `secretBytes` wherever you want it to live across
//    sessions. The library does not care which store you pick.
await persistVoterSecret(secretBytes);

// 2. Register the public key with the host.
await fetch("/api/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ public_key: publicKey }),
});

// 3. Wipe the in-memory copy. After this point the only readable
//    instance of the secret is whatever your `persistVoterSecret`
//    produced — nothing is sitting around in the JS heap.
secretBytes.fill(0);

Note on the threat model. Wiping the JS-side Uint8Array does not protect against XSS / a malicious script running while the secret is still loaded. What it does protect against is post-hoc memory inspection: core dumps, devtools snapshots taken later, swap partitions, extensions that scan page memory periodically. The window of exposure is reduced to the smallest interval the caller can manage. The library does its share by Zeroizing every Rust-side copy of the secret automatically.

Validating a stored secret key

is_valid_secret_key_wasm runs the same canonical-encoding and non-zero-scalar checks the sign_vote_*_wasm functions apply internally, but without producing anything — useful for surfacing a clear UI error before the voter has filled in the rest of the form. The input is wrapped in Zeroizing on the Rust side; the caller still owns wiping its own Uint8Array afterwards.

const secretBytes = await loadVoterSecret();
if (!is_valid_secret_key_wasm(secretBytes)) {
    secretBytes.fill(0);
    throw new Error("stored secret is corrupted or invalid");
}
// secretBytes is safe to feed into the sign_vote_*_wasm functions.

Returns false (never throws) on any malformed input: wrong length, non-canonical encoding, or the zero scalar.

Recovering a public key from a stored secret

derive_public_key_wasm re-derives the public key from a 32-byte secret key. The derivation is a pure scalar multiplication — no randomness — so the same secret always yields the same public key. Useful when a caller has persisted the secret key but needs to recover or re-display the corresponding public key (e.g. to re-register after a device migration, or to display a voter's ring entry in the UI without storing the public key separately).

The input is wrapped in Zeroizing on the Rust side; wipe the JS-side Uint8Array with .fill(0) once the call returns.

const secretBytes = await loadVoterSecret();
let publicKey;
try {
    // Returns the prefixed public key ("pk_…_…"), or throws on malformed
    // input (wrong length, non-canonical encoding, zero scalar).
    publicKey = derive_public_key_wasm(secretBytes);
} finally {
    secretBytes.fill(0);
}

Importing / exporting a secret key in the prefixed format

The wasm-bindgen flavour handles secret material as a Uint8Array so the JS caller can wipe it with .fill(0). But a voter who wants to back up or re-import their key works with the human-friendly prefixed string (sk_<hex>_<checksum>). These two functions bridge the gap:

  • secret_key_from_prefixed_wasm(str)import: validates the sk_ tag, the checksum and the canonical-scalar rule, then returns the 32-byte Uint8Array the rest of the API consumes. Throws a clear error (wrong prefix, mistyped checksum, non-canonical scalar) instead of yielding a silently-wrong key.
  • secret_key_to_prefixed_wasm(bytes)export: turns a raw Uint8Array (e.g. the secretBytes from generate_identity_wasm) into the sk_…_… string to show or download.
  • is_valid_prefixed_secret_key_wasm(str)validate only: true / false for live form feedback, never throws.
// --- Import: the voter pastes their backup string into a form ---
const pasted = form.secretKey.value.trim(); // "sk_…_…"

if (!is_valid_prefixed_secret_key_wasm(pasted)) {
    throw new Error("That doesn't look like a valid secret key.");
}

let secretBytes;
try {
    secretBytes = secret_key_from_prefixed_wasm(pasted); // → Uint8Array(32)
    // …persist it, then use it to sign…
    const [signature, keyImage] =
        sign_vote_str_wasm(secretBytes, "option-A", electionId, ring);
} finally {
    secretBytes?.fill(0);
}

// --- Export: let the voter back up a freshly generated key ---
const [freshBytes, publicKey] = generate_identity_wasm();
try {
    const backup = secret_key_to_prefixed_wasm(freshBytes); // "sk_…_…"
    showDownload(backup); // a String can't be .fill(0)'d — show it, then drop it
} finally {
    freshBytes.fill(0);
}

A String cannot be wiped from JS memory the way a Uint8Array can, so only call the export function at the moment you actually display or download the backup, and let the reference go out of scope right after.

Operation B — sign a ballot

// 1. Serialise the ballot ONCE into a string. This exact string is
//    what gets signed, what you send to the host, and what the host
//    must store verbatim. Do not re-stringify on the host —
//    verification compares the UTF-8 bytes of the string.
const ballot = JSON.stringify({ choice: "option-A" });

// 2. Election context the host has told the page about.
const electionId = "550e8400-e29b-41d4-a716-446655440000";

// 3. The full authorised ring — one entry per authorised voter,
//    including the current one. Each entry is a prefixed public key
//    ("pk_…_…", what `generate_identity_wasm` returns); bare hex is not
//    accepted at the WASM boundary. The module canonicalises the order
//    internally, so the server can return them in any order
//    (registration order, sorted, whatever):
//
//        GET /api/election/ring
//        200 OK
//        Content-Type: application/json
//        [
//          "pk_84e5498b443e3617cfa8d54d9699922e2733105f287cf5bbbe90174544875b0e_1a2b3c4d",
//          "pk_541e3d09e31ac28016ce4f652f591a4745920aff4103b4e3b272204812d4f153_5e6f7a8b",
//          ...
//        ]
//
//    Each entry is exactly what `PublicKey::to_prefixed()` produces.
const ring = await fetch("/api/election/ring").then(r => r.json());

// 4. Read the secret bytes back from wherever you persisted them.
//    `sign_vote_str_wasm` takes the secret as a `Uint8Array` of length
//    32. Wrap the call in try/finally so the in-memory copy of the
//    secret is wiped even on error.
const secretBytes = await loadVoterSecret();
let signature, keyImage;
try {
    // `sign_vote_str_wasm` throws on bad inputs (empty vote, empty
    // election ID, secret of the wrong length, signer not in the ring,
    // …). The Rust side wraps the incoming bytes in `Zeroizing` so the
    // WASM linear-memory copy is wiped on return. (For a binary ballot,
    // call `sign_vote_bytes_wasm` with a `Uint8Array` instead.)
    [signature, keyImage] = sign_vote_str_wasm(
        secretBytes,
        ballot,
        electionId,
        ring,
    );
} finally {
    // 5. Wipe the JS-side copy of the secret as soon as signing is
    //    done. The `Uint8Array` is the only place the secret lived in
    //    the JS heap; after `.fill(0)` it is unreadable.
    secretBytes.fill(0);
}

// 6. Send the proof + the ballot string to the host. The ballot must
//    arrive byte-for-byte unchanged, so transmit it as-is (here a
//    plain form field; a JSON body works too) and never re-serialise.
const form = new FormData();
form.append("election_id", electionId);
form.append("signature", signature);
form.append("key_image", keyImage);
form.append("ballot", ballot);

await fetch("/api/vote", { method: "POST", body: form });

Operation C — verify (server-side or in a Node host)

The same WASM module works in Node.js (or Deno / Bun) for hosts that prefer to keep verification inside a JS runtime instead of linking the Rust crate natively:

import init, { verify_vote_str_wasm } from "./pkg/crypto_vote.js";

await init();

// `ballot` is the exact string you stored verbatim when receiving the
// vote — read it back without any re-encoding or re-serialisation.
// (A binary ballot stored as bytes verifies with `verify_vote_bytes_wasm`.)
const isValid = verify_vote_str_wasm(
    ballot,
    electionId,
    signature,
    keyImage,
    ring,
);

// `verify_vote_str_wasm` never throws and never returns anything other
// than a boolean: any parse error / malformed input is just `false`.
if (!isValid) {
    return reject("invalid proof");
}

Host-side checklist

Before calling verify_vote_str_wasm / verify_vote_bytes_wasm, the host should:

  1. Read key_image from the submission and look it up in its per-election store. If present → reject (double vote).
  2. Otherwise call the matching verify_vote_*_wasm. On true, persist key_image to the store atomically with recording the ballot, so a crash between the two cannot let a voter slip through twice.

Extism plugin

The Extism flavour ships one .wasm artefact that runs in every Extism host SDK — browser via @extism/extism, Node, Deno, Bun, Python, Go, Rust, Java, even the standalone extism CLI. Same binary, same calls, regardless of the host language. Build it with --features extism --target wasm32-wasip1 (see the build matrix above); WASI provides the RNG, no extra wiring required.

Recommended split: pick the right flavour per role

The two flavours are not interchangeable for secret-handling code. The architectural split that matches their trade-offs:

Role Flavour Why
Voter device (browser, signs ballots) wasm-bindgen Secret is exposed as a mutable Uint8Array that the JS caller can .fill(0). Every Rust-side temporary is Zeroizing-wrapped. This is the only flavour built around memory hygiene for the secret.
Verifier (server, mobile app, audit tool, CLI tooling, …) Extism Verification never touches a secret — verify_vote_str / verify_vote_hex only handle public bytes (signature, key image, ring, ballot). One binary supports verifiers written in any host language.

The Extism flavour can technically run the sign_vote_* functions and generate_identity, but doing so on a voter device degrades secret hygiene compared to wasm-bindgen — see the next subsection. For verifiers and non-secret-handling tooling, the Extism flavour is the right default.

Secret-hygiene trade-off vs wasm-bindgen

Going through JSON means the secret key transits as a (prefixed) string in the plugin's input/output buffer. Three protections that the wasm-bindgen flavour provides are weakened or lost:

  • The Rust-side intermediate String holding the parsed secret is wrapped in Zeroizing (its heap allocation is overwritten when the sign call returns). However the JSON parser's internal buffers and the Extism PDK's input buffer in WASM linear memory are not under our control.
  • The JS-side String holding the secret is immutable — the host cannot call .fill(0) on it. It lives until V8 garbage-collects it (no guaranteed timing).
  • The Extism input/output buffers in WASM linear memory may persist between plugin calls (Extism re-uses the instance), so the secret bytes can linger across calls until a future allocation overwrites the region.

Net effect: the Extism flavour reduces post-hoc memory hygiene (core dumps, devtools snapshots taken later, swap to disk, periodic memory scans). It does not change the live attack surface — a script running in the same context while the secret is in memory can read it under both flavours. If your threat model prioritises the post-hoc class, sign on a voter device with the wasm-bindgen flavour.

Plugin function signatures

Sign and verify each come in two flavours, differing only in how the vote field is carried:

Every key / signature field below uses the prefixed form (pk_…, sk_…, ki_…, blsag_…), both in and out — bare hex is rejected at the plugin boundary (it is only accepted by the pure-Rust library API). Below, <pk> etc. denote a prefixed value; <vote> is unaffected. See Encoding formats.

Plugin function JSON input JSON output
generate_identity (empty) {"secret": <sk>, "public": <pk>}
derive_public_key {"secret": <sk>} {"public": <pk>}
sign_vote_str {"secret": <sk>, "vote": <str>, "election_id": <str>, "ring": [<pk>, …]} {"signature": <blsag>, "key_image": <ki>}
sign_vote_hex {"secret": <sk>, "vote": <hex>, "election_id": <str>, "ring": [<pk>, …]} {"signature": <blsag>, "key_image": <ki>}
verify_vote_str {"vote": <str>, "election_id": <str>, "signature": <blsag>, "key_image": <ki>, "ring": [<pk>, …]} {"valid": <bool>}
verify_vote_hex {"vote": <hex>, "election_id": <str>, "signature": <blsag>, "key_image": <ki>, "ring": [<pk>, …]} {"valid": <bool>}
is_valid_secret_key {"secret": <sk>} {"valid": <bool>}
  • _strvote is a plain JSON string; its UTF-8 bytes are the ballot, fed verbatim with no decoding. Ergonomic for text ballots (a label, a stringified JSON ballot). A JSON string only carries valid UTF-8, so this flavour can't represent a non-UTF-8 ballot.
  • _hexvote is hex-encoded bytes, decoded before hashing. Use it for arbitrary binary ballots (raw Protobuf, NUL bytes, any non-UTF-8 sequence). This hex concerns only the ballot; the keys, signatures and tags on the same wire use the prefixed format.

The two are just front doors to the same byte-level operation — the library always sees &[u8]. So a proof made with sign_vote_str verifies under verify_vote_hex and vice-versa, as long as the bytes match (signing "oui" with _str == signing "6f7569" with _hex). This is also what lets a ballot signed by either wasm-bindgen function verify here: only the vote bytes matter. A binary ballot signed in the browser with sign_vote_bytes_wasm is verified on the server by hex-encoding those stored bytes and calling verify_vote_hex.

derive_public_key re-derives the public key from a stored secret key — useful when a caller has persisted only the secret and needs to recover the corresponding ring entry. Input: {"secret": <sk>} (prefixed sk_…). Output: {"public": <pk>} (prefixed). Errors (bad prefix, bad checksum, bad hex, wrong length, zero scalar) are returned as plugin-level errors. The Rust-side copy of the secret is wrapped in Zeroizing; the same JSON-buffer caveat as sign_vote_* applies.

extism call crypto_vote-extism.wasm derive_public_key \
    --input '{"secret":"sk_…_…"}' --wasi
# → {"public":"pk_…_…"}

is_valid_secret_key is a pure utility that mirrors SecretKey::is_valid_prefixed: it returns {"valid": false} (never an error) for malformed input — bad prefix/checksum, bad hex, wrong length, non-canonical encoding, or the zero scalar. The Rust-side copy of the secret is wrapped in Zeroizing, but the same JSON-buffer caveat as the sign_vote_* functions applies; for secret-handling on a voter device, prefer the wasm-bindgen flavour.

Browser example

The browser snippet below exercises the identity, sign and verify functions for completeness, but in a real deployment a voter device should sign with the wasm-bindgen flavour for better secret hygiene. The Extism flavour in the browser is idiomatic for verifier roles: audit pages, results dashboards, mobile webview verifiers, etc.

// One-time install:  npm install @extism/extism
import createPlugin from "@extism/extism";

// `useWasi: true` is required because the plugin is built for
// wasm32-wasip1 (so `SysRng` can read random bytes from the WASI
// shim). The browser SDK ships a WASI polyfill internally.
const plugin = await createPlugin(
    "/static/crypto_vote-extism.wasm",
    { useWasi: true },
);

// --- Operation A — generate an identity ---
const { secret, public: publicKey } = await plugin
    .call("generate_identity", "")
    .then(out => out.json());

// `secret` is a prefixed string ("sk_…_…"). Persist it however you want;
// this flavour does not offer the `.fill(0)` zeroisation hook that the
// wasm-bindgen flavour does.
await persistVoterSecret(secret);

// --- Operation B — sign a ballot ---
// Text ballot → `sign_vote_str`, vote passed through unchanged, no hex
// step. (Binary ballot → `sign_vote_hex` with `vote` set to the hex of
// your bytes.)
const ballot = JSON.stringify({ choice: "option-A" });

const ring = await fetch("/api/election/ring").then(r => r.json());

const { signature, key_image } = await plugin
    .call("sign_vote_str", JSON.stringify({
        secret,
        vote:        ballot,
        election_id: "election-2026",
        ring,
    }))
    .then(out => out.json());

// --- Operation C — verify (also works server-side; see below) ---
// Use the flavour matching how the ballot was signed: `verify_vote_str`
// for a string vote, `verify_vote_hex` for a hex one.
const { valid } = await plugin
    .call("verify_vote_str", JSON.stringify({
        vote:        ballot,
        election_id: "election-2026",
        signature,
        key_image,
        ring,
    }))
    .then(out => out.json());

Node.js / Deno / Bun example

Same code as the browser — that is the whole point of the Extism flavour. The only difference is how you load the .wasm (a file path on the server, a URL in the browser):

import createPlugin from "@extism/extism";
import { readFileSync } from "node:fs";

const plugin = await createPlugin(
    { wasm: [{ data: readFileSync("./crypto_vote-extism.wasm") }] },
    { useWasi: true },
);

const { valid } = await plugin
    .call("verify_vote_str", JSON.stringify({ /* ... same shape ... */ }))
    .then(out => out.json());

The host SDK is also available for Python, Go, Rust, Java, and others — the JSON shapes above are identical for all of them.

Quick smoke test from the CLI

The extism standalone CLI is the fastest way to confirm the plugin loads and behaves correctly before integrating it anywhere:

# Install once: cargo install extism-cli   (or grab a release binary)
extism call crypto_vote-extism.wasm generate_identity --wasi
# → {"secret":"…","public":"…"}

Tests

cargo test

Tests cover round-trips, same-election deterministic tags, cross-election tag separation, ring-order independence, bit-level malleability resistance on both the signature and the key image, and every documented "invalid" case (tampered vote, tampered signature, swapped tag, wrong ring, subset/superset ring, malformed inputs).

Linting

Three commands mirror the CI lint job exactly — run them before pushing:

# 1. Formatting — must produce no diff.
cargo fmt --all -- --check

# 2. Clippy — default features (CLI + native library).
cargo clippy --all-targets -- -D warnings

# 3. Clippy — WASM feature, cross-compiled to wasm32 so wasm-bindgen
#    macros type-check correctly. Requires the target to be installed:
#    rustup target add wasm32-unknown-unknown
cargo clippy --no-default-features --features wasm \
    --target wasm32-unknown-unknown --lib -- -D warnings

To auto-fix formatting instead of just checking: cargo fmt --all.

Fuzzing

A cargo-fuzz harness lives in fuzz/ with four targets:

cargo install cargo-fuzz   # one-off
cargo +nightly fuzz run verify_vote      # parse + verify pipeline
cargo +nightly fuzz run parse_signature  # Signature::from_bytes
cargo +nightly fuzz run parse_keys       # PublicKey / SecretKey / KeyImage
cargo +nightly fuzz run roundtrip        # sign → verify differential

The fuzz/ package is excluded from the main workspace so it does not affect stable builds.

Layout

src/
├── lib.rs            — crate entry point, re-exports, top-level docs
├── error.rs          — `Error` enum (input parsing only)
├── types.rs          — PublicKey / SecretKey / Signature / KeyImage / VoteProof
├── identity.rs       — Operation A
├── blsag.rs          — Experimental election-scoped BLSAG implementation
├── signing.rs        — Operation B (ring canonicalisation + NFC of election_id)
├── verifying.rs      — Operation C
├── wasm.rs           — wasm-bindgen layer (feature-gated, Zeroizing secrets)
├── extism.rs         — Extism PDK layer (feature-gated, JSON-over-WASI)
└── main.rs           — CLI binary
tests/
├── integration.rs    — public-API round-trips, malleability, negative paths
└── upgrade_vectors.rs — frozen test vectors (protocol-drift tripwires)
fuzz/
├── Cargo.toml        — separate package, excluded from the workspace
└── fuzz_targets/     — four cargo-fuzz harnesses (see "Fuzzing")

License

This project is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See LICENSE for the full text.

The AGPL is a strong copyleft licence. In short: anyone running a modified version of this code as a network service must make their modifications available to its users. If that obligation is incompatible with your use case, please open an issue before integrating the crate.

About

Sign and verify anonymous, double-vote-resistant ballots with linkable BLSAG ring signatures over Ristretto255 + Blake2b-512. Native CLI and WebAssembly (browser + WASI) builds.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages