geronimo-captcha 1.0.0

Secure, AI-resistant, JavaScript-free CAPTCHA built in Rust. Confuses bots, but delights humans.
Documentation
use crate::error::{CaptchaError, Result};
use crate::image::NoiseOptions;
use crate::registry::ChallengeRegistry;
use crate::sprite::{SpriteFormat, SpriteTarget};
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<T: SpriteTarget>(&self) -> Result<challenge::CaptchaChallenge<T>> {
        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::<T>(
            sample_image,
            self.secret.as_slice(),
            &self.gen_opts,
            self.noise,
        )?;

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

        let (format, quality, lossless) = match self.gen_opts.sprite_format {
            SpriteFormat::Jpeg { quality } => ("jpeg", quality, false),
            SpriteFormat::Webp { quality, lossless } => (
                if lossless { "webp-lossless" } else { "webp" },
                quality,
                lossless,
            ),
        };

        info!(
            cell_size = self.gen_opts.cell_size,
            format = format,
            quality = quality,
            lossless = lossless,
            "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".into(),
            ));
        }

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

        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)
    }
}