Skip to main content

geronimo_captcha/
manager.rs

1use crate::challenge::{self, MAX_CHALLENGE_ID_LEN};
2use crate::error::{CaptchaError, Result};
3use crate::image::NoiseOptions;
4use crate::registry::ChallengeRegistry;
5use crate::samples::SampleSet;
6use crate::sprite::{SpriteFormat, SpriteTarget};
7use crate::utils::MAX_TTL_SECS;
8
9use image::RgbaImage;
10use std::sync::Arc;
11use tracing::{debug, info, warn};
12use zeroize::Zeroizing;
13
14const MIN_SECRET_LEN: usize = 32;
15
16pub struct CaptchaManager {
17    bases: Vec<RgbaImage>,
18    registry: Option<Arc<dyn ChallengeRegistry>>,
19    challenge_ttl: u64,
20    noise: NoiseOptions,
21    secret: Zeroizing<Vec<u8>>,
22    gen_opts: challenge::GenerationOptions,
23}
24
25impl CaptchaManager {
26    /// `secret` must be at least 32 bytes. It is zeroized on drop.
27    ///
28    /// Without a `registry` every challenge accepts nine guesses.
29    ///
30    /// `samples` is decoded once here.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`CaptchaError::InvalidInput`] if `secret` is shorter
35    /// than 32 bytes, if `challenge_ttl` is outside 1..=86400, or
36    /// if `noise`, `gen_opts` or `samples` are out of range.
37    /// Returns [`CaptchaError::Decode`] if a sample fails to decode.
38    pub fn new(
39        secret: String,
40        challenge_ttl: u64,
41        noise: NoiseOptions,
42        registry: Option<Arc<dyn ChallengeRegistry>>,
43        gen_opts: challenge::GenerationOptions,
44        samples: &SampleSet,
45    ) -> Result<Self> {
46        let secret = Zeroizing::new(secret.into_bytes());
47
48        if secret.len() < MIN_SECRET_LEN {
49            return Err(CaptchaError::InvalidInput(format!(
50                "secret must be at least {MIN_SECRET_LEN} bytes, got {}",
51                secret.len()
52            )));
53        }
54
55        if challenge_ttl == 0 || challenge_ttl > MAX_TTL_SECS {
56            return Err(CaptchaError::InvalidInput(format!(
57                "challenge_ttl {challenge_ttl} outside 1..={MAX_TTL_SECS}"
58            )));
59        }
60
61        noise.validate()?;
62        gen_opts.validate()?;
63
64        let bases = samples.decode_all(&gen_opts)?;
65
66        if registry.is_none() {
67            warn!("captcha manager built without a registry, brute-force is unrestricted");
68        }
69
70        info!(
71            rounds = gen_opts.rounds,
72            blind_guess_pct = 100.0 / 9f64.powi(i32::from(gen_opts.rounds)),
73            "captcha manager ready"
74        );
75
76        Ok(Self {
77            bases,
78            registry,
79            challenge_ttl,
80            noise,
81            secret,
82            gen_opts,
83        })
84    }
85
86    /// # Errors
87    ///
88    /// Returns [`CaptchaError::Decode`] or [`CaptchaError::Encode`]
89    /// when the sprite cannot be built, and [`CaptchaError::Clock`]
90    /// when the system clock predates the unix epoch.
91    pub fn generate_challenge<T: SpriteTarget>(&self) -> Result<challenge::CaptchaChallenge<T>> {
92        let challenge = challenge::generate::<T>(
93            &self.bases,
94            self.secret.as_slice(),
95            &self.gen_opts,
96            self.noise,
97        )?;
98
99        if let Some(reg) = &self.registry {
100            reg.register(&challenge.challenge_id)?;
101        }
102
103        let (format, quality, lossless) = match self.gen_opts.sprite_format {
104            SpriteFormat::Jpeg { quality } => ("jpeg", quality, false),
105            SpriteFormat::Webp { quality, lossless } => (
106                if lossless { "webp-lossless" } else { "webp" },
107                quality,
108                lossless,
109            ),
110        };
111
112        debug!(
113            cell_size = self.gen_opts.cell_size,
114            format = format,
115            quality = quality,
116            lossless = lossless,
117            "captcha generated"
118        );
119
120        Ok(challenge)
121    }
122
123    /// `selected` holds one index per round, in the order
124    /// the sprites were issued. Returns `Ok(false)` for
125    /// a well-formed guess that does not match.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`CaptchaError::InvalidInput`] for a
130    /// `challenge_id` that is empty or longer than an
131    /// issued id, a `selected` length other than
132    /// `GenerationOptions::rounds`, or any index
133    /// outside 1..=9. Returns [`CaptchaError::Registry`]
134    /// when the registry refuses the id, including a
135    /// correct guess that lost a race to an earlier one.
136    pub fn verify_challenge(&self, challenge_id: &str, selected: &[u8]) -> Result<bool> {
137        if challenge_id.is_empty() || challenge_id.len() > MAX_CHALLENGE_ID_LEN {
138            return Err(CaptchaError::InvalidInput(format!(
139                "challenge id length {} outside 1..={MAX_CHALLENGE_ID_LEN}",
140                challenge_id.len()
141            )));
142        }
143
144        if selected.len() != self.gen_opts.rounds as usize {
145            return Err(CaptchaError::InvalidInput(format!(
146                "expected {} indices, got {}",
147                self.gen_opts.rounds,
148                selected.len()
149            )));
150        }
151
152        if selected.iter().any(|&i| i == 0 || i > 9) {
153            return Err(CaptchaError::InvalidInput(
154                "Selected index out of bounds".into(),
155            ));
156        }
157
158        if let Some(registry) = &self.registry {
159            registry
160                .begin_attempt(challenge_id)
161                .inspect_err(|e| warn!("challenge refused by registry: {e}"))?;
162        }
163
164        let valid = challenge::verify(
165            self.secret.as_slice(),
166            challenge_id,
167            selected,
168            self.challenge_ttl,
169        );
170
171        match (valid, &self.registry) {
172            (true, Some(registry)) => {
173                registry
174                    .mark_verified(challenge_id)
175                    .inspect_err(|e| warn!("correct guess refused by registry: {e}"))?;
176                debug!("captcha verified successfully");
177            }
178            (true, None) => debug!("captcha verified successfully"),
179            (false, _) => debug!("captcha verification failed"),
180        }
181
182        Ok(valid)
183    }
184}