d5939db
//! 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<Arc<RedisCache>>,
}
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<RedisCache>) -> 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<Challenge, PowError> {
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<std::collections::BTreeMap<String, serde_json::Value>>,
) -> Result<Challenge, PowError> {
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<VerifySolutionResult, PowError> {
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<bool, PowError> {
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<VerifySolutionResult, PowError> {
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<String, PowError> {
if self.settings.hmac_secret.is_empty() {
Err(PowError::MissingSecret)
} else {
Ok(self.settings.hmac_secret.clone())
}
}
fn expires_at(&self) -> Result<Option<u64>, 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))
}
}