geronimo_captcha/
challenge.rs

1use crate::error::{CaptchaError, Result};
2use crate::image_ops::{NoiseOptions, rotate_image, sprite_to_base64, watermark_with_noise};
3use crate::utils::get_timestamp;
4
5use ab_glyph::{FontArc, PxScale};
6use base64::{Engine as _, prelude::BASE64_STANDARD};
7use hmac::{Hmac, Mac};
8use image::codecs::jpeg::JpegEncoder;
9use image::{
10    DynamicImage, GenericImage, ImageBuffer, ImageFormat, ImageReader, Limits, Rgba, imageops,
11};
12use imageproc::drawing::draw_text_mut;
13use once_cell::sync::Lazy;
14use rand::prelude::SliceRandom;
15use rand::{Rng, rng};
16#[cfg(feature = "parallel")]
17use rayon::prelude::*;
18use sha2::Sha256;
19use std::io::Cursor;
20use subtle::ConstantTimeEq;
21use uuid::Uuid;
22
23static FONT: Lazy<FontArc> = Lazy::new(|| {
24    FontArc::try_from_slice(include_bytes!("../assets/Roboto-Bold.ttf"))
25        .expect("embedded font should be valid")
26});
27
28type HmacSha256 = Hmac<Sha256>;
29
30pub struct CaptchaChallenge {
31    pub sprite_uri: String,
32    #[cfg(any(test, feature = "test-utils"))]
33    pub sprite: DynamicImage,
34    pub challenge_id: String,
35    pub timestamp: u64,
36    #[cfg(any(test, feature = "test-utils"))]
37    pub correct_number: u8,
38}
39
40#[derive(Clone)]
41pub struct GenerationOptions {
42    pub cell_size: u32,
43    pub jpeg_quality: u8,
44    pub limits: Option<Limits>,
45}
46
47impl Default for GenerationOptions {
48    fn default() -> Self {
49        Self {
50            cell_size: 150,
51            jpeg_quality: 70,
52            limits: None,
53        }
54    }
55}
56
57pub fn generate(
58    base_buf: &[u8],
59    secret: &[u8],
60    opts: &GenerationOptions,
61    noise: NoiseOptions,
62) -> Result<CaptchaChallenge> {
63    let (mut sprite, correct_number) = create_sprite(base_buf, opts)?;
64    watermark_with_noise(&mut sprite, noise);
65
66    let rgb = sprite.to_rgb8();
67    let dyn_rgb = DynamicImage::ImageRgb8(rgb);
68
69    let mut sprite_buf = Vec::new();
70    {
71        let mut encoder = JpegEncoder::new_with_quality(&mut sprite_buf, opts.jpeg_quality);
72        encoder
73            .encode_image(&dyn_rgb)
74            .map_err(|e| CaptchaError::EncodeError(format!("encode sprite as JPEG: {e}")))?;
75    }
76    let sprite_uri = sprite_to_base64(&sprite_buf, ImageFormat::Jpeg);
77
78    let (challenge_id, timestamp) = build_challenge_id(correct_number, secret)?;
79
80    #[cfg(any(test, feature = "test-utils"))]
81    let challenge = CaptchaChallenge {
82        sprite: dyn_rgb,
83        sprite_uri,
84        challenge_id,
85        timestamp,
86        correct_number,
87    };
88    #[cfg(not(any(test, feature = "test-utils")))]
89    let challenge = CaptchaChallenge {
90        sprite_uri,
91        challenge_id,
92        timestamp,
93    };
94
95    Ok(challenge)
96}
97
98fn create_sprite(base_buf: &[u8], opts: &GenerationOptions) -> Result<(DynamicImage, u8)> {
99    let mut reader = ImageReader::with_format(Cursor::new(base_buf), ImageFormat::Jpeg);
100    if let Some(limits) = opts.limits.clone() {
101        reader.limits(limits);
102    } else {
103        let mut limits = Limits::default();
104        limits.max_image_width = Some(4096);
105        limits.max_image_height = Some(4096);
106        limits.max_alloc = Some(128 * 1024 * 1024);
107
108        reader.limits(limits);
109    }
110
111    let base = reader
112        .decode()
113        .map_err(|e| CaptchaError::DecodeError(format!("load captcha sample image: {e}")))?
114        .resize_exact(
115            opts.cell_size,
116            opts.cell_size,
117            imageops::FilterType::Nearest,
118        );
119    let mut rng = rng();
120
121    let correct_angle = 0.0;
122    let incorrect_angles = [
123        38.0, 88.0, 114.0, 138.0, 176.0, 200.0, 229.0, 255.0, 278.0, 314.0, 320.0,
124    ];
125
126    let mut angles = Vec::with_capacity(1 + incorrect_angles.len());
127    angles.push(correct_angle);
128    angles.extend_from_slice(&incorrect_angles);
129
130    let precomputed: Vec<(f32, image::RgbaImage)> = {
131        #[cfg(feature = "parallel")]
132        {
133            angles
134                .par_iter()
135                .map(|&a| (a, rotate_image(&base, a).to_rgba8()))
136                .collect()
137        }
138        #[cfg(not(feature = "parallel"))]
139        {
140            angles
141                .iter()
142                .map(|&a| (a, rotate_image(&base, a).to_rgba8()))
143                .collect()
144        }
145    };
146
147    let mut tiles = vec![(true, correct_angle)];
148    let mut others = incorrect_angles.to_vec();
149
150    others.shuffle(&mut rng);
151
152    for &angle in others.iter().take(8) {
153        tiles.push((false, angle));
154    }
155
156    tiles.shuffle(&mut rng);
157
158    let font = &*FONT;
159    let cols = 3;
160    let rows = 3;
161    let spacing = 4;
162    let sprite_width = cols * opts.cell_size + (cols - 1) * spacing;
163    let sprite_height = rows * opts.cell_size + (rows - 1) * spacing;
164
165    let mut sprite_buf =
166        ImageBuffer::from_pixel(sprite_width, sprite_height, Rgba([255, 255, 255, 255]));
167
168    let mut correct_number = 0;
169
170    for (i, (is_correct, angle)) in tiles.iter().enumerate() {
171        // Create and draw each tile
172        let tile_scale = 0.5 + rng.random_range(0.0..0.3);
173        let shrink_size = (opts.cell_size as f32 * tile_scale) as u32;
174        let rotated = precomputed
175            .iter()
176            .find(|(a, _)| (*a - *angle).abs() < f32::EPSILON)
177            .map(|(_, img)| img)
178            .ok_or_else(|| CaptchaError::Internal("missing precomputed angle".into()))?;
179
180        let mut tile = image::imageops::resize(
181            rotated,
182            shrink_size,
183            shrink_size,
184            imageops::FilterType::Lanczos3,
185        );
186
187        let should_flip = rng.random_bool(0.5);
188        if should_flip {
189            tile = imageops::flip_horizontal(&tile);
190        }
191
192        let col = i as u32 % cols;
193        let row = i as u32 / cols;
194
195        let base_x = col * (opts.cell_size + spacing);
196        let base_y = row * (opts.cell_size + spacing);
197
198        let offset_x = (opts.cell_size - shrink_size) / 2;
199        let offset_y = (opts.cell_size - shrink_size) / 2;
200
201        let jitter_limit_x = offset_x as i32;
202        let jitter_limit_y = offset_y as i32;
203
204        let jitter_x = rng.random_range(-jitter_limit_x..=jitter_limit_x);
205        let jitter_y = rng.random_range(-jitter_limit_y..=jitter_limit_y);
206
207        let draw_x = (base_x as i32 + offset_x as i32 + jitter_x) as u32;
208        let draw_y = (base_y as i32 + offset_y as i32 + jitter_y) as u32;
209
210        sprite_buf
211            .copy_from(&tile, draw_x, draw_y)
212            .map_err(|e| CaptchaError::Internal(format!("copy tile into sprite buffer: {e}")))?;
213
214        // Draw the number label
215        let label = format!("{}", i + 1);
216
217        let label_x = draw_x.saturating_add(shrink_size).saturating_sub(16);
218        let label_y = draw_y.saturating_add(shrink_size).saturating_sub(16);
219
220        let scale_factor = rng.random_range(0.13..=0.17);
221        let scale = PxScale::from(opts.cell_size as f32 * scale_factor);
222
223        let color = Rgba([
224            rng.random_range(0..100),
225            rng.random_range(0..100),
226            rng.random_range(0..100),
227            255,
228        ]);
229
230        let offset_x = rng.random_range(0..=3);
231        let offset_y = rng.random_range(0..=3);
232
233        draw_text_mut(
234            &mut sprite_buf,
235            color,
236            (label_x + offset_x) as i32,
237            (label_y + offset_y) as i32,
238            scale,
239            &font,
240            &label,
241        );
242
243        if *is_correct {
244            correct_number = (i + 1) as u8;
245        }
246    }
247
248    Ok((DynamicImage::ImageRgba8(sprite_buf), correct_number))
249}
250
251fn build_challenge_id(correct_number: u8, secret: &[u8]) -> Result<(String, u64)> {
252    let timestamp = get_timestamp();
253    let nonce = Uuid::new_v4().to_string();
254
255    let mut mac = HmacSha256::new_from_slice(secret)
256        .map_err(|e| CaptchaError::Internal(format!("create HMAC: {e}")))?;
257    mac.update(nonce.as_bytes());
258    mac.update(&[correct_number]);
259    mac.update(&timestamp.to_be_bytes());
260
261    let code = BASE64_STANDARD.encode(mac.finalize().into_bytes());
262
263    Ok((format!("{nonce}:{timestamp}:{code}"), timestamp))
264}
265
266pub fn verify(secret: &[u8], challenge_id: &str, selected_index: u8, ttl: u64) -> bool {
267    let parts: Vec<&str> = challenge_id.split(':').collect();
268    if parts.len() != 3 {
269        return false;
270    }
271
272    let nonce = parts[0];
273    let timestamp: u64 = match parts[1].parse() {
274        Ok(t) => t,
275        Err(_) => return false,
276    };
277    let expected_code_b64 = parts[2];
278
279    let now = get_timestamp();
280    if now > timestamp.saturating_add(ttl) {
281        return false;
282    }
283
284    let mut mac = match HmacSha256::new_from_slice(secret) {
285        Ok(m) => m,
286        Err(_) => return false,
287    };
288    mac.update(nonce.as_bytes());
289    mac.update(&[selected_index]);
290    mac.update(&timestamp.to_be_bytes());
291
292    let computed = mac.finalize().into_bytes();
293
294    let expected = match BASE64_STANDARD.decode(expected_code_b64) {
295        Ok(bytes) => bytes,
296        Err(_) => return false,
297    };
298
299    if expected.len() != computed.len() {
300        return false;
301    }
302
303    computed[..].ct_eq(expected.as_slice()).into()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use base64::engine::general_purpose;
310    use std::collections::HashSet;
311    use std::thread::sleep;
312    use std::time::Duration;
313
314    const CHALLENGE_TTL: u64 = 60;
315    const SECRET: &[u8] = b"secret-key";
316
317    fn load_sample_image() -> Vec<u8> {
318        include_bytes!("../assets/sample1.jpg").to_vec()
319    }
320
321    fn generate_challenge() -> CaptchaChallenge {
322        let base = load_sample_image();
323        let opts = GenerationOptions {
324            cell_size: 150,
325            jpeg_quality: 70,
326            limits: None,
327        };
328        generate(&base, SECRET, &opts, NoiseOptions::default())
329            .expect("Failed to generate challenge")
330    }
331
332    #[test]
333    fn test_generate_and_verify() {
334        let challenge = generate_challenge();
335        let result = verify(
336            SECRET,
337            &challenge.challenge_id,
338            challenge.correct_number,
339            CHALLENGE_TTL,
340        );
341
342        // challenge
343        //     .sprite
344        //     .save(Path::new("examples/exampleX.jpg"))
345        //     .expect("Failed to save generated image");
346
347        assert!(result, "Challenge verification failed for correct index");
348    }
349
350    #[test]
351    fn test_verification_should_fail_for_wrong_guess() {
352        let challenge = generate_challenge();
353
354        let wrong = (challenge.correct_number + 1) % 9;
355        let valid = verify(SECRET, &challenge.challenge_id, wrong, 60);
356
357        assert!(!valid, "Verification should fail for wrong index");
358    }
359
360    #[test]
361    fn test_challenge_correct_index_should_be_random() {
362        let mut seen_indices = HashSet::new();
363
364        for _ in 0..100 {
365            let challenge = generate_challenge();
366            seen_indices.insert(challenge.correct_number);
367        }
368
369        assert!(
370            seen_indices.len() > 1,
371            "Correct index never changes. Challenge randomization failed"
372        );
373    }
374
375    #[test]
376    fn test_challenge_should_expire_after_ttl() {
377        let challenge = generate_challenge();
378
379        sleep(Duration::from_secs(2));
380
381        let expired = verify(SECRET, &challenge.challenge_id, challenge.correct_number, 1);
382
383        assert!(!expired, "Expired challenge passed verification");
384    }
385
386    #[test]
387    fn test_verify_timing_should_not_leak_answer() {
388        use std::time::Instant;
389
390        let challenge = generate_challenge();
391
392        let mut durations = vec![];
393        for i in 0..9 {
394            let start = Instant::now();
395            let _ = verify(SECRET, &challenge.challenge_id, i, 60);
396            durations.push(start.elapsed().as_nanos());
397        }
398
399        let min = *durations.iter().min().unwrap();
400        let max = *durations.iter().max().unwrap();
401        let delta = max - min;
402
403        println!("Timing min={min}ns, max={max}ns, delta={delta}ns");
404
405        // Allow a small margin (<50μs) due to CPU noise, but not large leak
406        assert!(
407            delta < 50_000,
408            "Timing delta too large ({delta}ns), possible side channel",
409        );
410    }
411
412    #[test]
413    fn test_no_false_positives_over_many_challenges() {
414        use std::time::Instant;
415
416        let mut false_positives = 0;
417        let mut durations = vec![];
418
419        for _ in 0..60 {
420            let start = Instant::now();
421            let challenge = generate_challenge();
422            durations.push(start.elapsed().as_nanos());
423
424            for guess in 0..9 {
425                if guess != challenge.correct_number
426                    && verify(SECRET, &challenge.challenge_id, guess, 60)
427                {
428                    false_positives += 1;
429                }
430            }
431        }
432
433        let min = *durations.iter().min().unwrap();
434        let max = *durations.iter().max().unwrap();
435        let delta = max - min;
436
437        println!("Timing min={min}ns, max={max}ns, delta={delta}ns");
438
439        assert_eq!(
440            false_positives, 0,
441            "Detected {false_positives} false positives — verification failed securely",
442        );
443    }
444
445    #[test]
446    fn test_uniqueness_hmac() {
447        let mut hmacs = HashSet::new();
448
449        for _ in 0..60 {
450            let challenge = generate_challenge();
451            let suffix8 = challenge
452                .challenge_id
453                .rsplit(':')
454                .next()
455                .unwrap_or("")
456                .chars()
457                .rev()
458                .take(8)
459                .collect::<String>();
460
461            hmacs.insert(suffix8);
462
463            sleep(Duration::from_millis(10));
464        }
465
466        assert_eq!(
467            hmacs.len(),
468            60,
469            "HMACs are not unique, potential rainbow table vulnerability"
470        );
471    }
472
473    #[test]
474    fn test_challenge_id_should_be_unforgeable() {
475        let challenge = generate_challenge();
476
477        let parts: Vec<&str> = challenge.challenge_id.split(':').collect();
478        let forged_index = (challenge.correct_number + 1) % 9;
479
480        // Recompute a forged HMAC for the wrong index
481        let mut mac = hmac::Hmac::<Sha256>::new_from_slice(b"BAD_SECRET").unwrap();
482        mac.update(parts[0].as_bytes());
483        mac.update(&[forged_index]);
484        mac.update(&parts[1].parse::<u64>().unwrap().to_be_bytes());
485        let forged_code = general_purpose::STANDARD.encode(mac.finalize().into_bytes());
486
487        let forged_challenge = format!("{}:{}:{}", parts[0], parts[1], forged_code);
488        let valid = verify(SECRET, &forged_challenge, forged_index, CHALLENGE_TTL);
489        assert!(
490            !valid,
491            "Forged challenge ID was accepted. HMAC security failure"
492        )
493    }
494}