Skip to main content

geronimo_captcha/
samples.rs

1use crate::challenge::GenerationOptions;
2use crate::error::{CaptchaError, Result};
3use crate::sprite::decode_base;
4
5use image::{ImageFormat, RgbaImage};
6use tracing::warn;
7
8const MAX_SAMPLES: usize = 4096;
9const MAX_CORPUS_BYTES: usize = 512 * 1024 * 1024;
10const RECOMMENDED_MIN_SAMPLES: usize = 32;
11
12const DEMO_SAMPLES: &[&[u8]] = &[
13    include_bytes!("../assets/sample1.jpg"),
14    include_bytes!("../assets/sample2.jpg"),
15    include_bytes!("../assets/sample3.jpg"),
16    include_bytes!("../assets/sample4.jpg"),
17    include_bytes!("../assets/sample5.jpg"),
18    include_bytes!("../assets/sample6.jpg"),
19    include_bytes!("../assets/sample7.jpg"),
20    include_bytes!("../assets/sample8.jpg"),
21];
22
23/// Encoded format of every image in a [`SampleSet`].
24/// The decoder is pinned to this
25/// rather than guessing from content.
26#[derive(Clone, Copy, Debug)]
27pub enum SampleFormat {
28    Jpeg,
29    Webp,
30}
31
32impl SampleFormat {
33    fn to_image_format(self) -> ImageFormat {
34        match self {
35            SampleFormat::Jpeg => ImageFormat::Jpeg,
36            SampleFormat::Webp => ImageFormat::WebP,
37        }
38    }
39}
40
41/// Source images a challenge draws its nine tiles from.
42///
43/// An attacker who holds the same images can render every
44/// rotation offline and match the upright tile without
45/// any model. Measured at 99.5% top-1 accuracy against
46/// a known corpus. Treat the corpus as the security boundary:
47/// supply your own, keep it large, and rotate it.
48///
49/// A corpus of `n` images is fully harvested in roughly
50/// `n * ln(n)` requests, after which its size no longer helps.
51pub struct SampleSet {
52    images: Vec<Vec<u8>>,
53    format: SampleFormat,
54}
55
56impl SampleSet {
57    /// # Errors
58    ///
59    /// Returns [`CaptchaError::InvalidInput`]
60    /// if `images` is empty, longer than 4096,
61    /// or contains an empty entry.
62    pub fn new(format: SampleFormat, images: Vec<Vec<u8>>) -> Result<Self> {
63        if images.is_empty() {
64            return Err(CaptchaError::InvalidInput(
65                "sample set must contain at least one image".into(),
66            ));
67        }
68
69        if images.len() > MAX_SAMPLES {
70            return Err(CaptchaError::InvalidInput(format!(
71                "sample set holds {} images, maximum is {MAX_SAMPLES}",
72                images.len()
73            )));
74        }
75
76        if let Some(i) = images.iter().position(Vec::is_empty) {
77            return Err(CaptchaError::InvalidInput(format!(
78                "sample image at index {i} is empty"
79            )));
80        }
81
82        if images.len() < RECOMMENDED_MIN_SAMPLES {
83            warn!(
84                samples = images.len(),
85                recommended = RECOMMENDED_MIN_SAMPLES,
86                "small sample corpus, a harvested corpus defeats the challenge"
87            );
88        }
89
90        Ok(Self { images, format })
91    }
92
93    /// The eight images published inside this crate.
94    ///
95    /// Anyone can download them from crates.io and
96    /// precompute the answer to every challenge they
97    /// produce. Tests, examples and benchmarks only.
98    pub fn demo_insecure() -> Self {
99        warn!("using the published demo sample set, challenges are trivially solvable");
100
101        Self {
102            images: DEMO_SAMPLES.iter().map(|b| b.to_vec()).collect(),
103            format: SampleFormat::Jpeg,
104        }
105    }
106
107    pub(crate) fn decode_all(&self, opts: &GenerationOptions) -> Result<Vec<RgbaImage>> {
108        let per_image = (opts.cell_size as usize)
109            .checked_mul(opts.cell_size as usize)
110            .and_then(|px| px.checked_mul(4))
111            .ok_or_else(|| CaptchaError::InvalidInput("cell_size overflows".into()))?;
112
113        let total = per_image
114            .checked_mul(self.images.len())
115            .ok_or_else(|| CaptchaError::InvalidInput("sample corpus overflows".into()))?;
116
117        if total > MAX_CORPUS_BYTES {
118            return Err(CaptchaError::InvalidInput(format!(
119                "decoded corpus needs {total} bytes, maximum is {MAX_CORPUS_BYTES}"
120            )));
121        }
122
123        let format = self.format.to_image_format();
124
125        self.images
126            .iter()
127            .map(|buf| decode_base(buf, format, opts))
128            .collect()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::{DecodeLimits, SpriteFormat};
136
137    fn opts(cell_size: u32) -> GenerationOptions {
138        GenerationOptions {
139            cell_size,
140            sprite_format: SpriteFormat::default(),
141            rounds: 1,
142            limits: DecodeLimits::default(),
143        }
144    }
145
146    #[test]
147    fn new_rejects_degenerate_corpora() {
148        assert!(SampleSet::new(SampleFormat::Jpeg, Vec::new()).is_err());
149
150        let too_many = vec![vec![1u8]; MAX_SAMPLES + 1];
151        assert!(SampleSet::new(SampleFormat::Jpeg, too_many).is_err());
152
153        let with_empty = vec![vec![1u8], Vec::new(), vec![1u8]];
154        assert!(SampleSet::new(SampleFormat::Jpeg, with_empty).is_err());
155
156        assert!(SampleSet::new(SampleFormat::Jpeg, vec![vec![1u8]]).is_ok());
157    }
158
159    #[test]
160    fn demo_set_decodes() {
161        let set = SampleSet::demo_insecure();
162
163        let bases = set.decode_all(&opts(64)).expect("decode demo set");
164        assert_eq!(bases.len(), DEMO_SAMPLES.len());
165        assert!(bases.iter().all(|b| b.width() == 64 && b.height() == 64));
166    }
167
168    #[test]
169    fn decode_all_rejects_oversized_corpus() {
170        let images = vec![vec![1u8]; 200];
171        let set = SampleSet::new(SampleFormat::Jpeg, images).expect("valid set");
172
173        let err = set.decode_all(&opts(1024)).expect_err("corpus cap");
174        assert!(matches!(err, CaptchaError::InvalidInput(_)));
175    }
176
177    #[test]
178    fn format_is_pinned_not_guessed() {
179        let jpeg = include_bytes!("../assets/sample1.jpg").to_vec();
180        let set = SampleSet::new(SampleFormat::Webp, vec![jpeg]).expect("valid set");
181
182        let err = set
183            .decode_all(&opts(64))
184            .expect_err("jpeg must not decode as webp");
185        assert!(matches!(err, CaptchaError::Decode(_)));
186    }
187
188    #[test]
189    fn webp_corpus_decodes() {
190        let jpeg = SampleSet::demo_insecure()
191            .decode_all(&opts(64))
192            .expect("decode demo")
193            .remove(0);
194
195        let rgb = image::DynamicImage::ImageRgba8(jpeg).into_rgb8();
196        let (bytes, _) = crate::image::encode_image(
197            &rgb,
198            &SpriteFormat::Webp {
199                quality: 80,
200                lossless: false,
201            },
202        )
203        .expect("encode webp");
204
205        let set = SampleSet::new(SampleFormat::Webp, vec![bytes]).expect("valid set");
206        let bases = set.decode_all(&opts(64)).expect("decode webp corpus");
207
208        assert_eq!(bases.len(), 1);
209        assert_eq!(bases[0].width(), 64);
210    }
211}