crypto-vote 0.4.2

Sign and verify anonymous, double-vote-resistant ballots with linkable BLSAG ring signatures over Ristretto255 + Blake2b-512. Native CLI and WebAssembly (browser + WASI) builds.
Documentation
//! `cryptovote` — command-line interface around the [`crypto_vote`]
//! library.
//!
//! The CLI is only built with the default `cli` feature on (it pulls in
//! `clap`). When compiling for WebAssembly, build with
//! `--no-default-features --features wasm` and this file is ignored.
//!
//! Three subcommands map one-to-one onto the three library operations:
//!
//! ```text
//! cryptovote keygen
//! cryptovote sign   --secret-file <file> --vote <text> --election-id <text> --ring <file>
//! cryptovote verify --vote <text> --election-id <text> \
//!                   --signature <hex> --key-image <hex> --ring <file>
//! ```
//!
//! The ring file is a plain text file with one prefixed public key
//! (`pk_…_…`) per line. Empty lines and lines starting with `#` are
//! ignored, so you can keep comments alongside the authorised list.
//! Like the rest of the CLI, it speaks the prefixed format exclusively;
//! bare hex is only accepted by the pure-Rust library API.

use clap::{Parser, Subcommand};
use crypto_vote::{
    KeyImage, PublicKey, SecretKey, Signature, generate_identity, sign_vote, verify_vote,
};
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;

// The CLI speaks the human-friendly prefixed form (`pk_…_…`, `sk_…_…`,
// `blsag_…_…`, `ki_…_…`) exclusively, both for what it prints and what it
// accepts. Bare hex is reserved for the pure-Rust library API, so every
// value parsed here goes through `from_prefixed`.

#[derive(Parser, Debug)]
#[command(
    name = "cryptovote",
    version,
    about = "Linkable ring signatures for verifiable voting (Ristretto255 + Blake2b-512)."
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Generate a new voter identity (prefixed secret + public key).
    Keygen,

    /// Sign a ballot.
    Sign {
        /// Secret key in prefixed form (`sk_…`). Pass `-` for stdin.
        #[arg(
            long,
            value_name = "HEX|-",
            conflicts_with = "secret_file",
            required_unless_present = "secret_file"
        )]
        secret: Option<String>,
        /// File containing the hex-encoded secret key.
        #[arg(
            long = "secret-file",
            value_name = "FILE",
            conflicts_with = "secret",
            required_unless_present = "secret"
        )]
        secret_file: Option<PathBuf>,
        /// Ballot text. Pass `-` to read from stdin.
        #[arg(long)]
        vote: String,
        /// Election identifier. Must be the same string the verifier
        /// uses (otherwise the signature will not validate).
        #[arg(long = "election-id")]
        election_id: String,
        /// File with one prefixed public key (`pk_…`) per line.
        #[arg(long)]
        ring: PathBuf,
    },

    /// Verify a ballot.
    Verify {
        /// Ballot text. Pass `-` to read from stdin.
        #[arg(long)]
        vote: String,
        /// Election identifier — same string the signer used.
        #[arg(long = "election-id")]
        election_id: String,
        /// Signature in prefixed form (`blsag_…`).
        #[arg(long)]
        signature: String,
        /// Linking tag in prefixed form (`ki_…`).
        #[arg(long = "key-image")]
        key_image: String,
        /// File with one prefixed public key (`pk_…`) per line.
        #[arg(long)]
        ring: PathBuf,
    },
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match dispatch(cli.command) {
        Ok(code) => code,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(2)
        }
    }
}

/// Inner entry point. Returns a proper `Result` so error reporting
/// happens in exactly one place (above).
fn dispatch(cmd: Command) -> Result<ExitCode, Box<dyn std::error::Error>> {
    match cmd {
        Command::Keygen => {
            let id = generate_identity();
            // Stable, machine-friendly output. Two `key=value` lines so
            // the caller can grep / cut without writing a JSON parser.
            println!("secret={}", id.secret_key.to_prefixed());
            println!("public={}", id.public_key.to_prefixed());
            Ok(ExitCode::SUCCESS)
        }

        Command::Sign {
            secret,
            secret_file,
            vote,
            election_id,
            ring,
        } => {
            if secret.as_deref() == Some("-") && vote == "-" {
                return Err(
                    "cannot read both --secret and --vote from stdin; use --secret-file".into(),
                );
            }
            let secret = read_secret(secret.as_deref(), secret_file.as_ref())?;
            let sk = SecretKey::from_prefixed(secret.trim())?;
            let vote_bytes = read_vote(&vote)?;
            let ring = read_ring(&ring)?;
            let proof = sign_vote(&sk, &vote_bytes, &election_id, &ring)?;
            println!("signature={}", proof.signature.to_prefixed());
            println!("key_image={}", proof.key_image.to_prefixed());
            Ok(ExitCode::SUCCESS)
        }

        Command::Verify {
            vote,
            election_id,
            signature,
            key_image,
            ring,
        } => {
            let vote_bytes = read_vote(&vote)?;
            let ring = read_ring(&ring)?;
            // Signature size depends on ring size, so we have to know
            // the ring length before we can parse the hex.
            let signature = Signature::from_prefixed(signature.trim(), ring.len())?;
            let key_image = KeyImage::from_prefixed(key_image.trim())?;
            let ok = verify_vote(&vote_bytes, &election_id, &signature, &key_image, &ring);
            if ok {
                println!("valid");
                Ok(ExitCode::SUCCESS)
            } else {
                println!("invalid");
                // Exit code 1 = "ran fine, answer is no". This is the
                // shell-friendly way to compose with `||`.
                Ok(ExitCode::from(1))
            }
        }
    }
}

/// Resolve the signing secret from a literal hex value, stdin, or a file.
fn read_secret(
    secret: Option<&str>,
    secret_file: Option<&PathBuf>,
) -> Result<String, Box<dyn std::error::Error>> {
    match (secret, secret_file) {
        (Some("-"), None) => {
            let mut buf = String::new();
            std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
            Ok(buf)
        }
        (Some(s), None) => Ok(s.to_owned()),
        (None, Some(path)) => Ok(fs::read_to_string(path)?),
        _ => Err("provide exactly one of --secret or --secret-file".into()),
    }
}

/// Resolve a `--vote` argument: either a literal string, or stdin if
/// the user passed `-`.
fn read_vote(arg: &str) -> std::io::Result<Vec<u8>> {
    if arg == "-" {
        let mut buf = Vec::new();
        std::io::Read::read_to_end(&mut std::io::stdin(), &mut buf)?;
        Ok(buf)
    } else {
        Ok(arg.as_bytes().to_vec())
    }
}

/// Parse a ring file: one hex public key per line, `#` comments and
/// blank lines tolerated.
fn read_ring(path: &PathBuf) -> Result<Vec<PublicKey>, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string(path)?;
    let mut keys = Vec::new();
    for (lineno, line) in raw.lines().enumerate() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        match PublicKey::from_prefixed(line) {
            Ok(pk) => keys.push(pk),
            // Wrap the parse error with the line number so users can
            // find the broken entry quickly.
            Err(e) => return Err(format!("ring file line {}: {e}", lineno + 1).into()),
        }
    }
    Ok(keys)
}