use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use rand::RngExt;
use serde_json::Value;
use challenge::{
CreateConfig, SolveConfig, create, parse_data_args, parse_server_payload, print_json,
read_input, read_json, solve, unix_now, verify_pair, verify_register_payload,
verify_server_payload,
};
#[derive(Debug, Parser)]
#[command(name = "challenge")]
#[command(about = "A lightweight CLI for ALTCHA Proof-of-Work v2 challenges")]
#[command(version)]
struct Cli {
#[arg(long, global = true)]
compact: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Create(CreateArgs),
Solve(SolveArgs),
Verify(VerifyArgs),
VerifyPayload(VerifyPayloadArgs),
VerifyServer(VerifyServerArgs),
}
#[derive(Debug, Parser)]
struct CreateArgs {
#[arg(long, default_value = "PBKDF2/SHA-256")]
algorithm: String,
#[arg(long, default_value_t = 5_000)]
cost: u32,
#[arg(long)]
counter: Option<u32>,
#[arg(long)]
random_counter: bool,
#[arg(long, default_value_t = 5_000)]
counter_min: u32,
#[arg(long, default_value_t = 10_000)]
counter_max: u32,
#[arg(long)]
expires_in: Option<u64>,
#[arg(long)]
expires_at: Option<u64>,
#[arg(long, env = "ALTCHA_HMAC_SECRET")]
hmac_secret: Option<String>,
#[arg(long)]
unsigned: bool,
#[arg(long, env = "ALTCHA_HMAC_KEY_SECRET")]
hmac_key_secret: Option<String>,
#[arg(long)]
key_length: Option<usize>,
#[arg(long)]
key_prefix: Option<String>,
#[arg(long)]
key_prefix_length: Option<usize>,
#[arg(long)]
memory_cost: Option<u32>,
#[arg(long)]
parallelism: Option<u32>,
#[arg(long = "data")]
data: Vec<String>,
}
#[derive(Debug, Parser)]
struct SolveArgs {
#[arg(long, short = 'c', default_value = "-")]
challenge: String,
#[arg(long, default_value_t = 0)]
counter_start: u32,
#[arg(long, default_value_t = 1)]
counter_step: u32,
#[arg(long, default_value_t = 90_000)]
timeout_ms: u64,
}
#[derive(Debug, Parser)]
struct VerifyArgs {
#[arg(long, short = 'c')]
challenge: String,
#[arg(long, short = 's')]
solution: String,
#[arg(long, env = "ALTCHA_HMAC_SECRET")]
secret: String,
#[arg(long, env = "ALTCHA_HMAC_KEY_SECRET")]
key_secret: Option<String>,
#[arg(long)]
fail_on_invalid: bool,
}
#[derive(Debug, Parser)]
struct VerifyPayloadArgs {
#[arg(long, short = 'p', default_value = "-")]
payload: String,
#[arg(long, env = "ALTCHA_HMAC_SECRET")]
secret: String,
#[arg(long, env = "ALTCHA_HMAC_KEY_SECRET")]
key_secret: Option<String>,
#[arg(long)]
fail_on_invalid: bool,
}
#[derive(Debug, Parser)]
struct VerifyServerArgs {
#[arg(long, short = 'p', default_value = "-")]
payload: String,
#[arg(long)]
base64: bool,
#[arg(long, env = "ALTCHA_HMAC_SECRET")]
secret: String,
#[arg(long)]
fail_on_invalid: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Create(args) => {
let challenge = create(build_create_config(args)?)?;
print_json(&challenge, cli.compact)?;
}
Commands::Solve(args) => {
let challenge = read_json(&args.challenge)?;
let solution = solve(
&challenge,
SolveConfig {
counter_start: args.counter_start,
counter_step: args.counter_step,
timeout_ms: args.timeout_ms,
},
)?;
print_json(&solution, cli.compact)?;
}
Commands::Verify(args) => {
validate_stdin_pair(&args.challenge, &args.solution)?;
let challenge = read_json(&args.challenge)?;
let solution = read_json(&args.solution)?;
let output = verify_pair(&challenge, &solution, &args.secret, args.key_secret)?;
let verified = output.verified;
print_json(&output, cli.compact)?;
maybe_fail_on_invalid(verified, args.fail_on_invalid)?;
}
Commands::VerifyPayload(args) => {
let payload: Value = read_json(&args.payload)?;
let output = verify_register_payload(&payload, &args.secret, args.key_secret)?;
let verified = output.verified;
print_json(&output, cli.compact)?;
maybe_fail_on_invalid(verified, args.fail_on_invalid)?;
}
Commands::VerifyServer(args) => {
let raw = read_input(&args.payload)?;
let payload = parse_server_payload(&raw, args.base64)?;
let output = verify_server_payload(&payload, &args.secret)?;
let verified = output.verified;
print_json(&output, cli.compact)?;
maybe_fail_on_invalid(verified, args.fail_on_invalid)?;
}
}
Ok(())
}
fn build_create_config(args: CreateArgs) -> Result<CreateConfig> {
if args.counter.is_some() && args.random_counter {
bail!("use either --counter or --random-counter, not both");
}
if args.expires_in.is_some() && args.expires_at.is_some() {
bail!("use either --expires-in or --expires-at, not both");
}
if args.unsigned && args.hmac_secret.is_some() {
bail!("use either --hmac-secret or --unsigned, not both");
}
if !args.unsigned && args.hmac_secret.is_none() {
bail!(
"--hmac-secret is required for signed production challenges; use --unsigned only for local demos"
);
}
let deterministic = args.counter.is_some() || args.random_counter;
if deterministic && args.hmac_key_secret.is_none() {
bail!("--hmac-key-secret is required with --counter or --random-counter");
}
let counter = if args.random_counter {
if args.counter_min > args.counter_max {
bail!("--counter-min must be <= --counter-max");
}
Some(rand::rng().random_range(args.counter_min..=args.counter_max))
} else {
args.counter
};
let expires_at = match (args.expires_in, args.expires_at) {
(Some(seconds), None) => Some(unix_now()? + seconds),
(None, Some(timestamp)) => Some(timestamp),
(None, None) => None,
(Some(_), Some(_)) => unreachable!("validated above"),
};
Ok(CreateConfig {
algorithm: args.algorithm,
cost: args.cost,
counter,
data: parse_data_args(&args.data)?,
expires_at,
hmac_signature_secret: args.hmac_secret,
hmac_key_signature_secret: args.hmac_key_secret,
key_length: args.key_length,
key_prefix: args.key_prefix,
key_prefix_length: args.key_prefix_length,
memory_cost: args.memory_cost,
parallelism: args.parallelism,
})
}
fn validate_stdin_pair(first: &str, second: &str) -> Result<()> {
if first == "-" && second == "-" {
bail!("only one input can be read from stdin; provide a file for either input");
}
Ok(())
}
fn maybe_fail_on_invalid(verified: bool, fail_on_invalid: bool) -> Result<()> {
if fail_on_invalid && !verified {
bail!("ALTCHA verification failed");
}
Ok(())
}