1use crate::error::{CaptchaError, Result};
2use crate::image::{NoiseOptions, blur_rgb, encode_image, watermark_with_noise};
3use crate::sprite::{SpriteFormat, SpriteTarget, create_sprite};
4use crate::utils::get_timestamp;
5
6use base64::{Engine as _, prelude::BASE64_STANDARD};
7use hmac::{Hmac, KeyInit, Mac};
8use image::{DynamicImage, RgbaImage};
9use rand::{RngExt, rng};
10use sha2::Sha256;
11use std::fmt::Write as _;
12use subtle::ConstantTimeEq;
13use uuid::Uuid;
14
15pub(crate) const MAX_ROUNDS: u8 = 5;
16
17pub(crate) const MAX_CHALLENGE_ID_LEN: usize = NONCE_LEN + 1 + 20 + 1 + MAC_B64_LEN;
19
20const MIN_CELL_SIZE: u32 = 16;
21const MAX_CELL_SIZE: u32 = 1024;
22
23const MAX_DECODE_DIM: u32 = 16_384;
24const MAX_DECODE_ALLOC: u64 = 1 << 30;
25
26const NONCE_LEN: usize = 36;
27const MAC_LEN: usize = 32;
28const MAC_B64_LEN: usize = 44;
29
30type HmacSha256 = Hmac<Sha256>;
31
32pub struct CaptchaChallenge<T> {
33 pub sprites: Vec<T>,
34 pub challenge_id: String,
35 pub timestamp: u64,
36 #[cfg(test)]
37 pub correct_numbers: Vec<u8>,
38}
39
40#[derive(Clone, Copy, Debug)]
42pub struct DecodeLimits {
43 pub max_width: u32,
45
46 pub max_height: u32,
48
49 pub max_alloc: u64,
52}
53
54impl Default for DecodeLimits {
55 fn default() -> Self {
56 Self {
57 max_width: 4096,
58 max_height: 4096,
59 max_alloc: 128 * 1024 * 1024,
60 }
61 }
62}
63
64#[derive(Clone, Copy, Debug)]
66pub struct GenerationOptions {
67 pub cell_size: u32,
71
72 pub sprite_format: SpriteFormat,
73
74 pub rounds: u8,
77
78 pub limits: DecodeLimits,
79}
80
81impl Default for GenerationOptions {
82 fn default() -> Self {
83 Self {
84 cell_size: 150,
85 sprite_format: SpriteFormat::default(),
86 rounds: 1,
87 limits: DecodeLimits::default(),
88 }
89 }
90}
91
92impl GenerationOptions {
93 pub fn validate(&self) -> Result<()> {
99 if !(MIN_CELL_SIZE..=MAX_CELL_SIZE).contains(&self.cell_size) {
100 return Err(CaptchaError::InvalidInput(format!(
101 "cell_size {} outside {MIN_CELL_SIZE}..={MAX_CELL_SIZE}",
102 self.cell_size
103 )));
104 }
105
106 let quality = self.sprite_format.quality();
107 if !(1..=100).contains(&quality) {
108 return Err(CaptchaError::InvalidInput(format!(
109 "sprite quality {quality} outside 1..=100"
110 )));
111 }
112
113 if !(1..=MAX_ROUNDS).contains(&self.rounds) {
114 return Err(CaptchaError::InvalidInput(format!(
115 "rounds {} outside 1..={MAX_ROUNDS}",
116 self.rounds
117 )));
118 }
119
120 for (name, dim) in [
121 ("max_width", self.limits.max_width),
122 ("max_height", self.limits.max_height),
123 ] {
124 if !(1..=MAX_DECODE_DIM).contains(&dim) {
125 return Err(CaptchaError::InvalidInput(format!(
126 "decode {name} {dim} outside 1..={MAX_DECODE_DIM}"
127 )));
128 }
129 }
130
131 if !(1..=MAX_DECODE_ALLOC).contains(&self.limits.max_alloc) {
132 return Err(CaptchaError::InvalidInput(format!(
133 "decode max_alloc {} outside 1..={MAX_DECODE_ALLOC}",
134 self.limits.max_alloc
135 )));
136 }
137
138 Ok(())
139 }
140}
141
142pub fn generate<T: SpriteTarget>(
143 bases: &[RgbaImage],
144 secret: &[u8],
145 opts: &GenerationOptions,
146 noise: NoiseOptions,
147) -> Result<CaptchaChallenge<T>> {
148 if bases.is_empty() {
149 return Err(CaptchaError::Internal("no sample images available".into()));
150 }
151
152 let rounds = opts.rounds as usize;
153 let mut picks = [0usize; MAX_ROUNDS as usize];
154
155 select_bases(bases.len(), &mut picks[..rounds]);
156
157 let mut sprites = Vec::with_capacity(rounds);
158 let mut correct_numbers = Vec::with_capacity(rounds);
159
160 for &pick in &picks[..rounds] {
161 let (mut sprite, correct_number) = create_sprite(&bases[pick], opts)?;
162 watermark_with_noise(&mut sprite, noise);
163
164 let rgb = blur_rgb(
165 DynamicImage::ImageRgba8(sprite).into_rgb8(),
166 noise.blur_sigma,
167 );
168
169 let (sprite_buf, mime) = encode_image(&rgb, &opts.sprite_format)?;
170
171 sprites.push(T::from_bytes(sprite_buf, mime));
172 correct_numbers.push(correct_number);
173 }
174
175 let (challenge_id, timestamp) = build_challenge_id(&correct_numbers, secret)?;
176
177 Ok(CaptchaChallenge {
178 sprites,
179 challenge_id,
180 timestamp,
181 #[cfg(test)]
182 correct_numbers,
183 })
184}
185
186fn select_bases(count: usize, picks: &mut [usize]) {
187 let mut rng = rng();
188 let distinct = count >= picks.len();
189
190 for i in 0..picks.len() {
191 loop {
192 let pick = rng.random_range(0..count);
193 if !distinct || !picks[..i].contains(&pick) {
194 picks[i] = pick;
195 break;
196 }
197 }
198 }
199}
200
201fn build_challenge_id(correct_numbers: &[u8], secret: &[u8]) -> Result<(String, u64)> {
202 let timestamp = get_timestamp()?;
203
204 let mut nonce_buf = Uuid::encode_buffer();
205 let nonce = Uuid::new_v4().hyphenated().encode_lower(&mut nonce_buf);
206
207 let mut mac = HmacSha256::new_from_slice(secret)
208 .map_err(|e| CaptchaError::Internal(format!("create HMAC: {e}")))?;
209 mac.update(nonce.as_bytes());
210 mac.update(&[correct_numbers.len() as u8]);
211 mac.update(correct_numbers);
212 mac.update(×tamp.to_be_bytes());
213
214 let mut id = String::with_capacity(MAX_CHALLENGE_ID_LEN);
215 id.push_str(nonce);
216 id.push(':');
217 write!(id, "{timestamp}").map_err(|e| CaptchaError::Internal(format!("format id: {e}")))?;
218 id.push(':');
219
220 BASE64_STANDARD.encode_string(mac.finalize().into_bytes(), &mut id);
221
222 Ok((id, timestamp))
223}
224
225pub fn verify(secret: &[u8], challenge_id: &str, selected: &[u8], ttl: u64) -> bool {
226 if challenge_id.len() > MAX_CHALLENGE_ID_LEN {
227 return false;
228 }
229
230 if selected.is_empty() || selected.len() > MAX_ROUNDS as usize {
231 return false;
232 }
233
234 let mut parts = challenge_id.split(':');
235 let (Some(nonce), Some(ts), Some(code), None) =
236 (parts.next(), parts.next(), parts.next(), parts.next())
237 else {
238 return false;
239 };
240
241 if nonce.len() != NONCE_LEN || code.len() != MAC_B64_LEN || !is_canonical_decimal(ts) {
242 return false;
243 }
244
245 let Ok(timestamp) = ts.parse::<u64>() else {
246 return false;
247 };
248
249 let Ok(now) = get_timestamp() else {
250 return false;
251 };
252
253 if now > timestamp.saturating_add(ttl) {
254 return false;
255 }
256
257 let Ok(mut mac) = HmacSha256::new_from_slice(secret) else {
258 return false;
259 };
260
261 mac.update(nonce.as_bytes());
262 mac.update(&[selected.len() as u8]);
263 mac.update(selected);
264 mac.update(×tamp.to_be_bytes());
265
266 let mut expected = [0u8; MAC_B64_LEN];
267 let Ok(written) = BASE64_STANDARD.decode_slice(code, &mut expected) else {
268 return false;
269 };
270
271 if written != MAC_LEN {
272 return false;
273 }
274
275 mac.finalize().into_bytes()[..]
276 .ct_eq(&expected[..MAC_LEN])
277 .into()
278}
279
280fn is_canonical_decimal(s: &str) -> bool {
281 match s.as_bytes() {
282 [] => false,
283 [b'0'] => true,
284 [b'0', ..] => false,
285 digits => digits.iter().all(u8::is_ascii_digit),
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::SpriteUri;
293 use base64::engine::general_purpose;
294 use std::collections::HashSet;
295 use std::thread::sleep;
296 use std::time::Duration;
297
298 const CHALLENGE_TTL: u64 = 60;
299 const SECRET: &[u8] = b"unit-test-secret-key-32-bytes-min!!";
300
301 fn opts(rounds: u8) -> GenerationOptions {
302 GenerationOptions {
303 cell_size: 150,
304 sprite_format: SpriteFormat::Jpeg { quality: 70 },
305 rounds,
306 limits: DecodeLimits::default(),
307 }
308 }
309
310 fn generate_rounds(rounds: u8) -> CaptchaChallenge<SpriteUri> {
311 let opts = opts(rounds);
312 let base = crate::sprite::decode_base(
313 include_bytes!("../assets/sample1.jpg"),
314 image::ImageFormat::Jpeg,
315 &opts,
316 )
317 .expect("decode sample");
318
319 generate::<SpriteUri>(
320 std::slice::from_ref(&base),
321 SECRET,
322 &opts,
323 NoiseOptions::default(),
324 )
325 .expect("Failed to generate challenge")
326 }
327
328 fn generate_challenge() -> CaptchaChallenge<SpriteUri> {
329 generate_rounds(1)
330 }
331
332 fn wrong_index(correct: u8) -> u8 {
333 (correct % 9) + 1
334 }
335
336 #[test]
337 fn generate_and_verify() {
338 let challenge = generate_challenge();
339
340 assert!(
341 verify(
342 SECRET,
343 &challenge.challenge_id,
344 &challenge.correct_numbers,
345 CHALLENGE_TTL,
346 ),
347 "Challenge verification failed for correct index"
348 );
349 }
350
351 #[test]
352 fn verification_should_fail_for_wrong_guess() {
353 let challenge = generate_challenge();
354 let wrong = [wrong_index(challenge.correct_numbers[0])];
355
356 assert!(
357 !verify(SECRET, &challenge.challenge_id, &wrong, CHALLENGE_TTL),
358 "Verification should fail for wrong index"
359 );
360 }
361
362 #[test]
363 fn challenge_correct_index_should_be_random() {
364 let mut seen_indices = HashSet::new();
365 for _ in 0..100 {
366 seen_indices.insert(generate_challenge().correct_numbers[0]);
367 }
368
369 assert!(
370 seen_indices.len() > 1,
371 "Correct index never changes. Challenge randomization failed"
372 );
373 }
374
375 #[test]
376 fn challenge_should_expire_after_ttl() {
377 let challenge = generate_challenge();
378
379 sleep(Duration::from_secs(2));
380
381 assert!(
382 !verify(
383 SECRET,
384 &challenge.challenge_id,
385 &challenge.correct_numbers,
386 1
387 ),
388 "Expired challenge passed verification"
389 );
390 }
391
392 #[test]
393 fn exactly_one_index_verifies() {
394 for _ in 0..60 {
395 let challenge = generate_challenge();
396
397 let accepted = (1u8..=9)
398 .filter(|&guess| verify(SECRET, &challenge.challenge_id, &[guess], CHALLENGE_TTL))
399 .count();
400
401 assert_eq!(
402 accepted, 1,
403 "expected exactly one accepted index, got {accepted}"
404 );
405 }
406 }
407
408 #[test]
409 fn rounds_draw_distinct_samples() {
410 for rounds in 1..=MAX_ROUNDS as usize {
411 let mut picks = [0usize; MAX_ROUNDS as usize];
412 for _ in 0..64 {
413 select_bases(8, &mut picks[..rounds]);
414
415 let distinct: HashSet<usize> = picks[..rounds].iter().copied().collect();
416
417 assert_eq!(distinct.len(), rounds, "two rounds share a sample image");
418 assert!(picks[..rounds].iter().all(|&p| p < 8));
419 }
420 }
421 }
422
423 #[test]
424 fn rounds_repeat_samples_when_corpus_is_smaller() {
425 let mut picks = [7usize; MAX_ROUNDS as usize];
426 select_bases(1, &mut picks[..3]);
427
428 assert_eq!(&picks[..3], &[0, 0, 0]);
429 }
430
431 #[test]
432 fn exactly_one_combination_verifies_across_rounds() {
433 for rounds in 2u8..=3 {
434 let challenge = generate_rounds(rounds);
435
436 assert_eq!(challenge.sprites.len(), rounds as usize);
437 assert_eq!(challenge.correct_numbers.len(), rounds as usize);
438
439 let mut guess = vec![1u8; rounds as usize];
440 let mut accepted = 0;
441
442 loop {
443 if verify(SECRET, &challenge.challenge_id, &guess, CHALLENGE_TTL) {
444 accepted += 1;
445 }
446
447 let mut i = 0;
448 while i < guess.len() {
449 guess[i] += 1;
450
451 if guess[i] <= 9 {
452 break;
453 }
454
455 guess[i] = 1;
456 i += 1;
457 }
458
459 if i == guess.len() {
460 break;
461 }
462 }
463
464 assert_eq!(
465 accepted, 1,
466 "rounds={rounds}: expected exactly one accepted combination of 9^{rounds}"
467 );
468 }
469 }
470
471 #[test]
472 fn round_count_is_bound_into_the_mac() {
473 let challenge = generate_rounds(2);
474 let correct = &challenge.correct_numbers;
475
476 assert!(!verify(
477 SECRET,
478 &challenge.challenge_id,
479 &correct[..1],
480 CHALLENGE_TTL
481 ));
482
483 let mut padded = correct.clone();
484 padded.push(1);
485
486 assert!(!verify(
487 SECRET,
488 &challenge.challenge_id,
489 &padded,
490 CHALLENGE_TTL
491 ));
492
493 assert!(!verify(SECRET, &challenge.challenge_id, &[], CHALLENGE_TTL));
494 }
495
496 #[test]
497 fn malformed_challenge_ids_are_rejected() {
498 let challenge = generate_challenge();
499 let parts: Vec<&str> = challenge.challenge_id.split(':').collect();
500 let (nonce, ts, code) = (parts[0], parts[1], parts[2]);
501
502 let malformed = [
503 String::new(),
504 nonce.to_string(),
505 format!("{nonce}:{ts}"),
506 format!("{nonce}:{ts}:{code}:extra"),
507 format!("{nonce}x:{ts}:{code}"),
508 format!("{nonce}:{ts}:{code}x"),
509 format!("{nonce}:not-a-number:{code}"),
510 format!("{nonce}:+{ts}:{code}"),
511 format!("{nonce}:0{ts}:{code}"),
512 format!(":{ts}:{code}"),
513 ];
514
515 for id in malformed {
516 assert!(
517 !verify(SECRET, &id, &challenge.correct_numbers, CHALLENGE_TTL),
518 "malformed challenge id accepted: {id:?}"
519 );
520 }
521 }
522
523 #[test]
524 fn uniqueness_hmac() {
525 let mut hmacs = HashSet::new();
526 for _ in 0..60 {
527 let challenge = generate_challenge();
528 let code = challenge
529 .challenge_id
530 .rsplit(':')
531 .next()
532 .unwrap_or("")
533 .to_string();
534
535 hmacs.insert(code);
536 }
537
538 assert_eq!(
539 hmacs.len(),
540 60,
541 "HMACs are not unique, potential rainbow table vulnerability"
542 );
543 }
544
545 #[test]
546 fn challenge_id_should_be_unforgeable() {
547 let challenge = generate_challenge();
548
549 let parts: Vec<&str> = challenge.challenge_id.split(':').collect();
550 let forged = [wrong_index(challenge.correct_numbers[0])];
551
552 let mut mac = HmacSha256::new_from_slice(b"BAD_SECRET").unwrap();
553 mac.update(parts[0].as_bytes());
554 mac.update(&[forged.len() as u8]);
555 mac.update(&forged);
556 mac.update(&parts[1].parse::<u64>().unwrap().to_be_bytes());
557
558 let forged_code = general_purpose::STANDARD.encode(mac.finalize().into_bytes());
559
560 let forged_challenge = format!("{}:{}:{}", parts[0], parts[1], forged_code);
561
562 assert!(
563 !verify(SECRET, &forged_challenge, &forged, CHALLENGE_TTL),
564 "Forged challenge ID was accepted. HMAC security failure"
565 );
566 }
567
568 #[test]
569 fn options_validation_rejects_out_of_range() {
570 for cell_size in [0, 1, MIN_CELL_SIZE - 1, MAX_CELL_SIZE + 1, u32::MAX] {
571 let opts = GenerationOptions {
572 cell_size,
573 ..GenerationOptions::default()
574 };
575
576 assert!(
577 opts.validate().is_err(),
578 "cell_size {cell_size} should be rejected"
579 );
580 }
581
582 for quality in [0, 101, u8::MAX] {
583 let opts = GenerationOptions {
584 sprite_format: SpriteFormat::Jpeg { quality },
585 ..GenerationOptions::default()
586 };
587
588 assert!(
589 opts.validate().is_err(),
590 "quality {quality} should be rejected"
591 );
592 }
593
594 for rounds in [0, MAX_ROUNDS + 1, u8::MAX] {
595 let opts = GenerationOptions {
596 rounds,
597 ..GenerationOptions::default()
598 };
599
600 assert!(
601 opts.validate().is_err(),
602 "rounds {rounds} should be rejected"
603 );
604 }
605
606 let bad_limits = [
607 DecodeLimits {
608 max_width: 0,
609 ..DecodeLimits::default()
610 },
611 DecodeLimits {
612 max_width: MAX_DECODE_DIM + 1,
613 ..DecodeLimits::default()
614 },
615 DecodeLimits {
616 max_height: 0,
617 ..DecodeLimits::default()
618 },
619 DecodeLimits {
620 max_height: MAX_DECODE_DIM + 1,
621 ..DecodeLimits::default()
622 },
623 DecodeLimits {
624 max_alloc: 0,
625 ..DecodeLimits::default()
626 },
627 DecodeLimits {
628 max_alloc: MAX_DECODE_ALLOC + 1,
629 ..DecodeLimits::default()
630 },
631 ];
632
633 for limits in bad_limits {
634 let opts = GenerationOptions {
635 limits,
636 ..GenerationOptions::default()
637 };
638
639 assert!(
640 opts.validate().is_err(),
641 "limits {limits:?} should be rejected"
642 );
643 }
644
645 assert!(GenerationOptions::default().validate().is_ok());
646 }
647
648 #[test]
649 fn timestamp_encoding_is_canonical() {
650 assert!(is_canonical_decimal("0"));
651 assert!(is_canonical_decimal("1730534400"));
652 assert!(is_canonical_decimal("18446744073709551615"));
653
654 for s in ["", "+1", "-1", "01", "00", "1 ", " 1", "1_0", "1e3", "٣"] {
655 assert!(!is_canonical_decimal(s), "{s:?} accepted as canonical");
656 }
657 }
658}