challenge 0.1.0

A lightweight CLI for ALTCHA Proof-of-Work v2 challenges.
Documentation
//! Reusable helpers for the `challenge` ALTCHA CLI.
//!
//! The binary in `src/main.rs` is intentionally thin; most validation and JSON
//! shaping lives here so it can be unit-tested without spawning the CLI.

use std::{
    collections::BTreeMap,
    fs,
    io::{self, Read},
    path::PathBuf,
    time::{SystemTime, UNIX_EPOCH},
};

use altcha::{
    Challenge, CreateChallengeOptions, ServerSignaturePayload, Solution, SolveChallengeOptions,
    VerifySolutionOptions, create_challenge as altcha_create_challenge,
    solve_challenge as altcha_solve_challenge, verify_server_signature, verify_solution,
};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// Runtime options used to create an ALTCHA challenge.
#[derive(Debug, Clone)]
pub struct CreateConfig {
    pub algorithm: String,
    pub cost: u32,
    pub counter: Option<u32>,
    pub data: Option<BTreeMap<String, Value>>,
    pub expires_at: Option<u64>,
    pub hmac_signature_secret: Option<String>,
    pub hmac_key_signature_secret: Option<String>,
    pub key_length: Option<usize>,
    pub key_prefix: Option<String>,
    pub key_prefix_length: Option<usize>,
    pub memory_cost: Option<u32>,
    pub parallelism: Option<u32>,
}

impl Default for CreateConfig {
    fn default() -> Self {
        Self {
            algorithm: "PBKDF2/SHA-256".to_string(),
            cost: 5_000,
            counter: None,
            data: None,
            expires_at: None,
            hmac_signature_secret: None,
            hmac_key_signature_secret: None,
            key_length: None,
            key_prefix: None,
            key_prefix_length: None,
            memory_cost: None,
            parallelism: None,
        }
    }
}

/// Runtime options used to solve an ALTCHA challenge.
#[derive(Debug, Clone)]
pub struct SolveConfig {
    pub counter_start: u32,
    pub counter_step: u32,
    pub timeout_ms: u64,
}

impl Default for SolveConfig {
    fn default() -> Self {
        Self {
            counter_start: 0,
            counter_step: 1,
            timeout_ms: 90_000,
        }
    }
}

/// Stable JSON output for `challenge verify` and `challenge verify-payload`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyOutput {
    pub verified: bool,
    pub expired: bool,
    pub invalid_signature: Option<bool>,
    pub invalid_solution: Option<bool>,
    pub time: f64,
}

/// Stable JSON output for `challenge verify-server`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyServerOutput {
    pub verified: bool,
    pub expired: bool,
    pub invalid_signature: bool,
    pub invalid_solution: bool,
    pub time: f64,
    pub verification_data: Option<Value>,
}

/// Create a challenge from a validated config.
pub fn create(config: CreateConfig) -> Result<Challenge> {
    let mut options = CreateChallengeOptions {
        algorithm: config.algorithm,
        cost: config.cost,
        ..Default::default()
    };

    options.counter = config.counter;
    options.data = config.data;
    options.expires_at = config.expires_at;
    options.hmac_signature_secret = config.hmac_signature_secret;
    options.hmac_key_signature_secret = config.hmac_key_signature_secret;
    options.key_prefix_length = config.key_prefix_length;
    options.memory_cost = config.memory_cost;
    options.parallelism = config.parallelism;

    if let Some(value) = config.key_length {
        options.key_length = value;
    }

    if let Some(value) = config.key_prefix {
        options.key_prefix = value;
    }

    altcha_create_challenge(options).context("failed to create ALTCHA challenge")
}

/// Solve a challenge. Returns an error when the configured timeout elapses.
pub fn solve(challenge: &Challenge, config: SolveConfig) -> Result<Solution> {
    if config.counter_step == 0 {
        bail!("counter_step must be greater than zero");
    }

    let mut options = SolveChallengeOptions::new(challenge);
    options.counter_start = config.counter_start;
    options.counter_step = config.counter_step;
    options.timeout_ms = config.timeout_ms;

    altcha_solve_challenge(options)
        .context("failed to solve ALTCHA challenge")?
        .ok_or_else(|| anyhow!("no solution found before timeout"))
}

/// Verify a submitted solution against the original challenge.
pub fn verify_pair(
    challenge: &Challenge,
    solution: &Solution,
    hmac_signature_secret: &str,
    hmac_key_signature_secret: Option<String>,
) -> Result<VerifyOutput> {
    let mut options = VerifySolutionOptions::new(challenge, solution, hmac_signature_secret);
    options.hmac_key_signature_secret = hmac_key_signature_secret;

    let result = verify_solution(options).context("failed to verify ALTCHA solution")?;

    Ok(VerifyOutput {
        verified: result.verified,
        expired: result.expired,
        invalid_signature: result.invalid_signature,
        invalid_solution: result.invalid_solution,
        time: result.time,
    })
}

/// Verify a `/register` request body containing either:
///
/// - `{ "altcha": { "challenge": ..., "solution": ... }, ... }`
/// - `{ "challenge": ..., "solution": ... }`
pub fn verify_register_payload(
    payload: &Value,
    hmac_signature_secret: &str,
    hmac_key_signature_secret: Option<String>,
) -> Result<VerifyOutput> {
    let (challenge, solution) = extract_challenge_and_solution(payload)?;
    verify_pair(
        &challenge,
        &solution,
        hmac_signature_secret,
        hmac_key_signature_secret,
    )
}

/// Verify an ALTCHA Sentinel server-signature payload.
pub fn verify_server_payload(
    payload: &ServerSignaturePayload,
    hmac_secret: &str,
) -> Result<VerifyServerOutput> {
    let result = verify_server_signature(payload, hmac_secret)
        .context("failed to verify ALTCHA Sentinel server signature")?;

    let verification_data = result.verification_data.as_ref().map(|data| {
        json!({
            "classification": &data.classification,
            "email": &data.email,
            "expire": data.expire,
            "fields": &data.fields,
            "fieldsHash": &data.fields_hash,
            "id": &data.id,
            "ipAddress": &data.ip_address,
            "reasons": &data.reasons,
            "score": data.score,
            "time": data.time,
            "verified": data.verified,
            "extra": &data.extra,
        })
    });

    Ok(VerifyServerOutput {
        verified: result.verified,
        expired: result.expired,
        invalid_signature: result.invalid_signature,
        invalid_solution: result.invalid_solution,
        time: result.time,
        verification_data,
    })
}

/// Parse repeated `--data key=value` flags into signed challenge metadata.
pub fn parse_data_args(values: &[String]) -> Result<Option<BTreeMap<String, Value>>> {
    if values.is_empty() {
        return Ok(None);
    }

    let mut data = BTreeMap::new();
    for item in values {
        let (key, raw_value) = item
            .split_once('=')
            .ok_or_else(|| anyhow!("--data values must use key=value syntax: {item}"))?;

        if key.trim().is_empty() {
            bail!("--data key cannot be empty: {item}");
        }

        let value = serde_json::from_str(raw_value)
            .unwrap_or_else(|_| Value::String(raw_value.to_string()));
        data.insert(key.to_string(), value);
    }

    Ok(Some(data))
}

/// Return Unix time in seconds.
pub fn unix_now() -> Result<u64> {
    Ok(SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before the Unix epoch")?
        .as_secs())
}

/// Read a UTF-8 input file or stdin when path is `-`.
pub fn read_input(path: &str) -> Result<String> {
    if path == "-" {
        let mut input = String::new();
        io::stdin()
            .read_to_string(&mut input)
            .context("failed to read stdin")?;
        Ok(input)
    } else {
        fs::read_to_string(PathBuf::from(path)).with_context(|| format!("failed to read {path}"))
    }
}

/// Read and deserialize a JSON file or stdin when path is `-`.
pub fn read_json<T>(path: &str) -> Result<T>
where
    T: serde::de::DeserializeOwned,
{
    let input = read_input(path)?;
    serde_json::from_str(&input).with_context(|| format!("invalid JSON in {path}"))
}

/// Decode an ALTCHA Sentinel payload from JSON or base64-encoded JSON.
pub fn parse_server_payload(raw: &str, is_base64: bool) -> Result<ServerSignaturePayload> {
    let bytes = if is_base64 {
        base64::engine::general_purpose::STANDARD
            .decode(raw.trim())
            .context("failed to base64-decode ALTCHA Sentinel payload")?
    } else {
        raw.as_bytes().to_vec()
    };

    serde_json::from_slice(&bytes).context("failed to parse ALTCHA Sentinel payload JSON")
}

/// Print any serializable value as pretty or compact JSON.
pub fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<()> {
    if compact {
        println!("{}", serde_json::to_string(value)?);
    } else {
        println!("{}", serde_json::to_string_pretty(value)?);
    }
    Ok(())
}

fn extract_challenge_and_solution(payload: &Value) -> Result<(Challenge, Solution)> {
    let source = payload.get("altcha").unwrap_or(payload);

    let challenge_value = source
        .get("challenge")
        .ok_or_else(|| anyhow!("payload must contain altcha.challenge or challenge"))?;
    let solution_value = source
        .get("solution")
        .ok_or_else(|| anyhow!("payload must contain altcha.solution or solution"))?;

    let challenge = serde_json::from_value(challenge_value.clone())
        .context("failed to parse embedded challenge")?;
    let solution = serde_json::from_value(solution_value.clone())
        .context("failed to parse embedded solution")?;

    Ok((challenge, solution))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn parse_data_args_supports_json_and_strings() {
        let args = vec![
            "action=register".to_string(),
            "userId=123".to_string(),
            "premium=true".to_string(),
            "roles=[\"user\",\"admin\"]".to_string(),
        ];

        let data = parse_data_args(&args).unwrap().unwrap();

        assert_eq!(
            data.get("action"),
            Some(&Value::String("register".to_string()))
        );
        assert_eq!(data.get("userId"), Some(&json!(123)));
        assert_eq!(data.get("premium"), Some(&json!(true)));
        assert_eq!(data.get("roles"), Some(&json!(["user", "admin"])));
    }

    #[test]
    fn parse_data_args_rejects_missing_separator() {
        let args = vec!["action".to_string()];
        assert!(parse_data_args(&args).is_err());
    }

    #[test]
    fn parse_data_args_rejects_empty_key() {
        let args = vec!["=register".to_string()];
        assert!(parse_data_args(&args).is_err());
    }

    #[test]
    fn solve_config_rejects_zero_step() {
        let config = SolveConfig {
            counter_step: 0,
            ..Default::default()
        };

        let challenge = create(CreateConfig {
            hmac_signature_secret: Some("test-secret".to_string()),
            key_prefix: Some("00".to_string()),
            cost: 1,
            ..Default::default()
        })
        .unwrap();

        assert!(solve(&challenge, config).is_err());
    }
}