geronimo-captcha 0.2.0

Secure, AI-resistant, JavaScript-free CAPTCHA built in Rust. Confuses bots, but delights humans.
Documentation
use crate::error::{CaptchaError, Result};
use crate::image_ops::NoiseOptions;
use crate::registry::ChallengeRegistry;
use crate::{RegistryCheckResult, challenge};

use rand::prelude::IndexedRandom;
use rand::rng;
use std::sync::Arc;
use tracing::{info, warn};
use zeroize::Zeroizing;

pub const SAMPLE_IMAGES: &[&[u8]] = &[
    include_bytes!("../assets/sample1.jpg"),
    include_bytes!("../assets/sample2.jpg"),
    include_bytes!("../assets/sample3.jpg"),
    include_bytes!("../assets/sample4.jpg"),
    include_bytes!("../assets/sample5.jpg"),
    include_bytes!("../assets/sample6.jpg"),
    include_bytes!("../assets/sample7.jpg"),
];

pub struct CaptchaManager {
    registry: Option<Arc<dyn ChallengeRegistry>>,
    challenge_ttl: u64,
    noise: NoiseOptions,
    secret: Zeroizing<Vec<u8>>,
    gen_opts: challenge::GenerationOptions,
}

impl CaptchaManager {
    pub fn new(
        secret: String,
        challenge_ttl: u64,
        noise: NoiseOptions,
        registry: Option<Arc<dyn ChallengeRegistry>>,
        gen_opts: challenge::GenerationOptions,
    ) -> Self {
        Self {
            registry,
            challenge_ttl,
            noise,
            secret: Zeroizing::new(secret.into_bytes()),
            gen_opts,
        }
    }

    pub fn generate_challenge(&self) -> Result<challenge::CaptchaChallenge> {
        let sample_image = match SAMPLE_IMAGES.choose(&mut rng()) {
            Some(img) => *img,
            None => return Err(CaptchaError::Internal("no sample images available".into())),
        };

        let challenge = challenge::generate(
            sample_image,
            self.secret.as_slice(),
            &self.gen_opts,
            self.noise,
        )?;

        if let Some(reg) = &self.registry {
            reg.register(&challenge.challenge_id);
        }

        info!(
            cell_size = self.gen_opts.cell_size,
            jpeg_quality = self.gen_opts.jpeg_quality,
            "captcha generated"
        );

        Ok(challenge)
    }

    pub fn verify_challenge(&self, challenge_id: &str, selected_index: u8) -> Result<bool> {
        if challenge_id.is_empty() {
            return Err(CaptchaError::InvalidInput("Challenge ID cannot be empty"));
        }

        if selected_index == 0 || selected_index > 9 {
            return Err(CaptchaError::InvalidInput("Selected index out of bounds"));
        }

        if let Some(registry) = &self.registry {
            let result = registry.check(challenge_id);
            if result != RegistryCheckResult::Ok {
                warn!("challenge rejected by registry: {result}");
                return Err(CaptchaError::Registry(result));
            }
        }

        let valid = challenge::verify(
            self.secret.as_slice(),
            challenge_id,
            selected_index,
            self.challenge_ttl,
        );

        if valid {
            if let Some(registry) = &self.registry {
                registry.verify(challenge_id);
            }

            info!("captcha verified successfully");
        } else if let Some(registry) = &self.registry {
            registry.note_attempt(challenge_id, false);
            warn!("captcha verification failed");
        }

        Ok(valid)
    }
}