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};
#[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,
}
}
}
#[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,
}
}
}
#[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,
}
#[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>,
}
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")
}
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"))
}
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,
})
}
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,
)
}
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,
})
}
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))
}
pub fn unix_now() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before the Unix epoch")?
.as_secs())
}
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}"))
}
}
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}"))
}
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")
}
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());
}
}