//! Altcha Proof-of-Work (PoW) CAPTCHA module. //! //! Provides a type-safe wrapper around the [`altcha`] crate for generating //! PoW challenges and verifying client-submitted solutions. //! //! # Example //! //! ```no_run //! use auth::pow::{PowService, PowSettings}; //! //! let settings = PowSettings { //! hmac_secret: "a-very-secret-value".to_string(), //! ..PowSettings::default() //! }; //! let service = PowService::new(settings); //! //! // Issue a challenge to the client. //! let challenge = service.generate_challenge().unwrap(); //! // ... the client solves it and returns a `Solution` ... //! let solution = altcha::solve_challenge(altcha::SolveChallengeOptions::new(&challenge)) //! .unwrap() //! .unwrap(); //! let result = service.verify_solution(&challenge, &solution).unwrap(); //! assert!(result.verified); //! ``` mod config; mod error; pub use config::PowSettings; pub use error::PowError; pub use altcha::{ Challenge, ChallengeParameters, CreateChallengeOptions, HmacAlgorithm, Solution, VerifySolutionOptions, VerifySolutionResult, }; use std::collections::BTreeMap; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use altcha::{create_challenge, verify_solution}; use base64::Engine; use cache::redis::RedisCache; use sha2::{Digest, Sha256}; use uuid::Uuid; /// A service for generating and verifying Altcha PoW CAPTCHAs. /// /// Cheap to clone; hold a single instance in shared application state. /// /// When a Redis handle is attached (via [`PowService::with_kv`]) the service /// additionally provides client-bound, single-use challenges: each issued /// challenge is recorded in Redis and consumed atomically on verification, and /// the issuing client id is embedded in the challenge data. #[derive(Clone)] pub struct PowService { settings: PowSettings, kv: Option>, } impl PowService { /// Create a new service from the given settings. pub fn new(settings: PowSettings) -> Self { Self { settings, kv: None, } } /// Attach a Redis handle so the service can issue single-use, /// client-bound challenges. pub fn with_kv(mut self, kv: Arc) -> Self { self.kv = Some(kv); self } /// The one-time issuance key for a challenge, derived from its canonical /// serialization so the client may echo the challenge verbatim. fn issued_key(challenge: &Challenge) -> String { let json = serde_json::to_string(challenge).unwrap_or_default(); let digest = Sha256::digest(json.as_bytes()); format!( "auth:pow:issued:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) ) } /// The settings backing this service. pub fn settings(&self) -> &PowSettings { &self.settings } /// Generate a new signed PoW challenge for the client to solve. /// /// Returns an error if the HMAC secret is not configured or the underlying /// library fails. pub fn generate_challenge(&self) -> Result { self.generate_challenge_with(None) } /// Generate a challenge with an optional set of extra metadata embedded in /// the challenge's `data` field. pub fn generate_challenge_with( &self, data: Option>, ) -> Result { let secret = self.require_secret()?; let expires_at = self.expires_at()?; let options = CreateChallengeOptions { algorithm: self.settings.algorithm.clone(), cost: self.settings.cost, data, expires_at, hmac_signature_secret: Some(secret.clone()), key_length: self.settings.key_length, key_prefix: self.settings.key_prefix.clone(), memory_cost: self.settings.memory_cost, parallelism: self.settings.parallelism, ..Default::default() }; create_challenge(options).map_err(PowError::from) } /// Verify a client-submitted solution against a previously issued challenge. /// /// Returns an error only when verification could not be performed (e.g. the /// HMAC secret is missing or the library failed). A non-`Err` result may /// still have `verified == false`; inspect [`VerifySolutionResult`] for the /// reason (expired, invalid signature, invalid solution). pub fn verify_solution( &self, challenge: &Challenge, solution: &Solution, ) -> Result { let secret = self.require_secret()?; let options = VerifySolutionOptions::new(challenge, solution, secret); verify_solution(options).map_err(PowError::from) } /// Convenience wrapper that returns `Ok(true)` only when the solution is /// verified and not expired, mapping every failure to a [`PowError`]. pub fn verify(&self, challenge: &Challenge, solution: &Solution) -> Result { let result = self.verify_solution(challenge, solution)?; if result.verified { Ok(true) } else if result.expired { Err(PowError::Expired) } else if result.invalid_signature == Some(true) { Err(PowError::InvalidSignature) } else { Err(PowError::InvalidSolution) } } /// Issue a challenge bound to a specific client id. /// /// The challenge embeds `cid` (the caller's rate-limit cookie value) in its /// `data` and records a single-use marker in Redis so a later verification /// can atomically consume it. Returns the challenge and its TTL in seconds. pub async fn issue_challenge_bound( &self, cid: &str, ) -> Result<(Challenge, u64), PowError> { let kv = self.kv.as_ref().ok_or_else(|| { PowError::Storage("pow redis handle not attached".to_string()) })?; let mut data = BTreeMap::new(); data.insert("cid".into(), serde_json::Value::String(cid.to_string())); data.insert("nonce".into(), serde_json::Value::String(Uuid::new_v4().to_string())); let challenge = self.generate_challenge_with(Some(data))?; let ttl = self .settings .challenge_ttl .whole_seconds() .max(1) as u64; let key = Self::issued_key(&challenge); kv.set_bytes(&key, b"1".to_vec(), ttl) .await .map_err(|e| PowError::Storage(e.to_string()))?; Ok((challenge, ttl)) } /// Verify a solution against a client-bound, single-use challenge. /// /// Atomically consumes the issuance marker first — a challenge that was /// never issued (or was already used / replayed) is rejected with /// [`PowError::NotIssued`]. When `expected_cid` is provided the challenge's /// embedded `data.cid` must match it, rejecting challenges issued for a /// different client. pub async fn verify_solution_once( &self, challenge: &Challenge, solution: &Solution, expected_cid: Option<&str>, ) -> Result { let kv = self.kv.as_ref().ok_or_else(|| { PowError::Storage("pow redis handle not attached".to_string()) })?; let key = Self::issued_key(challenge); let consumed = kv .getdel_bytes(&key) .await .map_err(|e| PowError::Storage(e.to_string()))?; if consumed.is_none() { return Err(PowError::NotIssued); } if let Some(cid) = expected_cid { let bound = challenge .parameters .data .as_ref() .and_then(|d| d.get("cid")) .and_then(|v| v.as_str()); if bound != Some(cid) { return Err(PowError::ClientMismatch); } } self.verify_solution(challenge, solution) } fn require_secret(&self) -> Result { if self.settings.hmac_secret.is_empty() { Err(PowError::MissingSecret) } else { Ok(self.settings.hmac_secret.clone()) } } fn expires_at(&self) -> Result, PowError> { let seconds: u64 = self .settings .challenge_ttl .whole_seconds() .try_into() .map_err(|_| PowError::UnsupportedAlgorithm("invalid challenge ttl".to_string()))?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| PowError::UnsupportedAlgorithm("system clock error".to_string()))? .as_secs(); Ok(Some(now + seconds)) } }