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 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 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(×tamp.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(×tamp.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 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 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 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}