geronimo_captcha/
manager.rs1use 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 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 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 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}