Skip to main content

geronimo_captcha/
challenge.rs

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
17// NONCE_LEN + ':' + u64::MAX digits + ':' + MAC_B64_LEN
18pub(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/// Ceilings applied to a sample image before it is decoded.
41#[derive(Clone, Copy, Debug)]
42pub struct DecodeLimits {
43    /// Must be within 1..=16384.
44    pub max_width: u32,
45
46    /// Must be within 1..=16384.
47    pub max_height: u32,
48
49    /// Decoder allocation ceiling in bytes.
50    /// Must be within 1..=1073741824.
51    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/// Sprite geometry and encoding parameters.
65#[derive(Clone, Copy, Debug)]
66pub struct GenerationOptions {
67    /// Edge length of one grid cell, in pixels.
68    /// Must be within 16..=1024; the sprite is
69    /// three cells wide plus 4px gutters.
70    pub cell_size: u32,
71
72    pub sprite_format: SpriteFormat,
73
74    /// Sprites the client must solve in one submission.
75    /// Must be within 1..=5. Each round draws its own sample image.
76    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    /// # Errors
94    ///
95    /// Returns [`CaptchaError::InvalidInput`] if `cell_size`,
96    /// the format quality, `rounds` or any [`DecodeLimits`]
97    /// field is outside its documented range.
98    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(&timestamp.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(&timestamp.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}