Skip to main content

stenoxide_core/generate/
mod.rs

1//! Building a container *around* a payload, instead of hiding a payload inside
2//! one.
3//!
4//! # Who this is for
5//!
6//! Someone with no usable photograph. A laptop with no comfortable way to move
7//! pictures across from a phone, or a camera that only ever emits JPEG or HEIC —
8//! both lossy, both leaving the 8x8 grid the validation layer refuses, and
9//! neither repaired by converting the file to PNG. Until this mode existed that
10//! user had no way through at all.
11//!
12//! # Why generating is *more* secure than embedding, not less
13//!
14//! Embedding modifies an image, and a modification is a thing a detector can
15//! hunt for; the argument is only ever about how well it is hidden. Generating
16//! allows something an embedder cannot do: draw each sample from the cover
17//! distribution *conditioned on its least significant bit being the ciphertext
18//! bit it must carry*. Rejection sampling does that in about two draws, and
19//! then, for a uniform carrier bit,
20//!
21//! ```text
22//! sum over b of  P(sample = v | LSB = b) P(b)  =  P(sample = v)
23//! ```
24//!
25//! exactly, provided the least significant bit of the unconditioned
26//! distribution is a fair coin. The container that carries a message and the
27//! container that carries nothing are draws from **one** distribution. There
28//! are not two hypotheses to separate, so there is no statistic to find and no
29//! future detector that changes the answer. The naive construction — overwrite
30//! the least significant bit rather than draw conditioned on it — forfeits
31//! exactly this, and is measurably worse than doing nothing; the reasoning is
32//! with the sampler that refuses to use it.
33//!
34//! Three consequences follow, and the code below depends on all of them:
35//!
36//! - **The 0.02 bpp cap does not apply here.** It exists because a photograph's
37//!   distribution is unknown to the sender. Here the sender *is* the
38//!   distribution, and every sample carries a bit: 1.45 MB in a 2000x2000
39//!   container against roughly 7 KB for the embedding path.
40//! - **The HILL cost map and the trellis take no part.** There is no cost to
41//!   minimise when every position is equally free.
42//! - **"One image + one password = one message" holds by construction.** Each
43//!   generation is a new container with a different perceptual hash, and
44//!   therefore a different salt, key and nonce. For the embedding path that
45//!   rule is a matter of the user's discipline; here it cannot be broken.
46//!
47//! # What this mode does not do
48//!
49//! **It hides which, not whether.** The two hypotheses it equalises are
50//! "generated around a message" and "generated around nothing". It says nothing
51//! about "generated" versus "photographed": the container looks like a
52//! synthetic texture, and someone looking at a folder of them sees images
53//! nobody has an obvious reason to keep. Against "which of these hundred
54//! carries the message?" it is a complete answer. Against "why do you have this
55//! folder?" it is no answer at all.
56//!
57//! # Untying the circularity
58//!
59//! The key comes from the perceptual hash of the container, which now depends
60//! on the ciphertext, which depends on the key. The knot unties because the
61//! hash reads a 32x32 thumbnail where some four thousand grain samples average
62//! away to nothing, and layer 1 already refuses any container whose
63//! coefficients sit within `5.0` of their median. So a draft container fixes
64//! the hash, the hash fixes the key, and the final container is *checked* to
65//! hash the same before anything is written.
66//!
67//! **The draft never touches the disk.** It is built in memory and handed
68//! straight to the gates. A draft on disk would be the original cover, and the
69//! original cover not existing is precisely what this mode buys.
70//!
71//! # The seed is key material
72//!
73//! There is no cover to subtract, but an adversary who can reproduce the
74//! generator's random state can regenerate the container and read the
75//! difference — and the confirmation is unmistakable, because the right state
76//! reproduces the image and a wrong one differs in millions of samples. So the
77//! generator is seeded with 32 bytes from the system CSPRNG and from nothing
78//! else: never a timestamp, never a counter, never anything derived from the
79//! password. The seed is not persisted, printed or logged anywhere.
80
81mod carrier;
82mod texture;
83
84use std::fmt;
85use std::path::Path;
86
87use rand::rngs::{StdRng, SysRng};
88use rand::{Rng, SeedableRng, TryRng};
89use zeroize::Zeroizing;
90
91use crate::cost::hill::HillCostProvider;
92use crate::cost::CostProvider;
93use crate::crypto::aead::{
94    compress, decompress, AEADCipher, AEADError, CryptoError, XChaCha20Poly1305Cipher,
95    STENOXIDE_AAD,
96};
97use crate::crypto::expand::{expand_master_key, DerivedKeys, ExpandError};
98use crate::crypto::kdf::{Argon2Kdf, KdfError, KeyDeriver};
99use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
100use crate::image_io::jpeg_detect::detect_jpeg_artifacts;
101use crate::image_io::phash::compute_stable_phash;
102use crate::image_io::validate::{MAX_PIXELS, MIN_DIMENSION};
103use crate::pipeline::error::OutputError;
104use crate::pipeline::frame::write_png;
105
106use self::carrier::{draw_free, draw_with_lsb};
107use self::texture::Texture;
108
109pub use self::carrier::RejectionExhausted;
110
111/// Smallest side a generated container may have, in pixels.
112///
113/// Exactly the floor [`crate::image_io::validate`] applies to a container read
114/// from disk: a container this mode draws has to be one that mode would accept
115/// back, so the two share the number rather than each naming their own. It is
116/// also, for the texture, the smallest side whose cell scale the perceptual-hash
117/// gate reliably accepts — the reason the side used to be fixed here.
118pub const MIN_CONTAINER_SIDE: u32 = MIN_DIMENSION;
119
120/// Largest pixel count a generated container may have.
121///
122/// The same ceiling the loader refuses above, and for the same reason: a
123/// receiver has to analyse whatever a sender draws, and that analysis costs
124/// memory linear in the pixel count. A container the sender could draw but the
125/// receiver could not load would be useless to both.
126pub const MAX_CONTAINER_PIXELS: u64 = MAX_PIXELS;
127
128/// Side of the square container generated when no size is requested.
129///
130/// The historical default, kept as the behaviour of the size-less call: it is
131/// the minimum, so it is the smallest — and therefore least conspicuous — file
132/// the mode will produce.
133pub const DEFAULT_CONTAINER_SIDE: u32 = MIN_CONTAINER_SIDE;
134
135/// Channels of a generated container. It is written as 8-bit RGB.
136const CHANNELS: usize = 3;
137
138/// Bytes of the Poly1305 tag that rides at the end of the ciphertext.
139const TAG_BYTES: usize = 16;
140
141/// Bytes of the length header at the head of the encrypted buffer.
142///
143/// A big-endian `u32` counting the compressed payload that follows it.
144const LENGTH_HEADER_BYTES: usize = 4;
145
146/// Texture seeds tried before the attempt is abandoned.
147///
148/// A field passes the gates at something between two and four seeds in six, so
149/// sixty-four candidates turn acceptance into a certainty: `0.67^64` is about
150/// `1e-11`.
151const MAX_CANDIDATES: u32 = 64;
152
153/// Bytes of the seed the generator is started from.
154const SEED_BYTES: usize = 32;
155
156/// The size of the container to draw, checked against the two size gates.
157///
158/// A validated pair rather than two loose integers: the only way to obtain one
159/// is [`ContainerDimensions::new`], which refuses anything the loader would
160/// refuse, so no code downstream has to re-check a width or a height. A larger
161/// container carries more — capacity is a straight function of its pixel count —
162/// but every size this type admits is one a receiver can load and one whose
163/// texture feeds the hash gate the same octave the default does; see
164/// [`crate::generate::texture`] for why enlarging is safe rather than merely
165/// tolerated.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct ContainerDimensions {
168    /// Width, in pixels. At least [`MIN_CONTAINER_SIDE`].
169    width: u32,
170    /// Height, in pixels. At least [`MIN_CONTAINER_SIDE`].
171    height: u32,
172}
173
174impl ContainerDimensions {
175    /// A dimensions pair, if it clears both gates a loaded container is held to.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`GenerateError::DimensionsOutOfRange`] when either side is below
180    /// [`MIN_CONTAINER_SIDE`], or when the two multiply to more than
181    /// [`MAX_CONTAINER_PIXELS`]. The product is taken in [`u64`] so that two
182    /// large sides cannot wrap into a small count and slip past the ceiling.
183    pub fn new(width: u32, height: u32) -> Result<Self, GenerateError> {
184        let out_of_range = || GenerateError::DimensionsOutOfRange {
185            width,
186            height,
187            min_side: MIN_CONTAINER_SIDE,
188            max_pixels: MAX_CONTAINER_PIXELS,
189        };
190
191        if width < MIN_CONTAINER_SIDE || height < MIN_CONTAINER_SIDE {
192            return Err(out_of_range());
193        }
194        if u64::from(width) * u64::from(height) > MAX_CONTAINER_PIXELS {
195            return Err(out_of_range());
196        }
197
198        Ok(Self { width, height })
199    }
200
201    /// Width of the container, in pixels.
202    pub fn width(&self) -> u32 {
203        self.width
204    }
205
206    /// Height of the container, in pixels.
207    pub fn height(&self) -> u32 {
208        self.height
209    }
210
211    /// Ciphertext bytes a container of this size carries.
212    ///
213    /// One bit per sample, tag included: the ciphertext occupies the container
214    /// exactly, to the last sample it can fill.
215    fn capacity(self) -> usize {
216        self.width as usize * self.height as usize * CHANNELS / 8
217    }
218
219    /// Compressed payload bytes a container of this size admits.
220    ///
221    /// What is left of the capacity once the authentication tag and the length
222    /// header are paid for.
223    fn payload_capacity(self) -> usize {
224        self.capacity().saturating_sub(TAG_BYTES + LENGTH_HEADER_BYTES)
225    }
226}
227
228impl Default for ContainerDimensions {
229    /// The square container the size-less call produces; see
230    /// [`DEFAULT_CONTAINER_SIDE`].
231    fn default() -> Self {
232        Self {
233            width: DEFAULT_CONTAINER_SIDE,
234            height: DEFAULT_CONTAINER_SIDE,
235        }
236    }
237}
238
239/// What one generation produced.
240///
241/// None of these figures travels with the container, and none of them is a
242/// secret the caller does not already hold: the container is always the same
243/// size whatever it carries, which is the point of filling it.
244#[derive(Debug)]
245pub struct GenerateReport {
246    /// Dimensions of the container as `(width, height)`, in pixels.
247    pub image_dimensions: (u32, u32),
248    /// Compressed payload bytes the container was built around.
249    ///
250    /// The message after Zstandard, not its length: the plaintext length is not
251    /// something the container carries, and reporting it here would suggest
252    /// otherwise.
253    pub payload_bytes: usize,
254    /// Compressed payload bytes a container of this size admits.
255    pub capacity_bytes: usize,
256}
257
258/// Everything that can go wrong between a plaintext and a generated container.
259#[derive(Debug)]
260pub enum GenerateError {
261    /// The system random number generator could not be read.
262    ///
263    /// Fatal rather than papered over: every alternative source of a seed is
264    /// one an adversary can reproduce, and a container generated from a
265    /// guessable seed is one they can regenerate and compare against.
266    Entropy(String),
267    /// The compressed payload is larger than the requested container can hold.
268    PayloadTooLarge {
269        /// Payload bytes after compression.
270        payload: usize,
271        /// Compressed payload bytes the requested container admits.
272        available: usize,
273        /// How far over the limit the payload is, in bytes.
274        deficit: usize,
275        /// Side of the smallest square container that would admit this payload,
276        /// rounded up to a round figure for quoting to a user, or `None` when
277        /// no permitted container is large enough. A caller with a size to
278        /// suggest reads it from here rather than solving the quadratic itself.
279        recommended_side: Option<u32>,
280    },
281    /// The requested container size is outside the permitted range.
282    DimensionsOutOfRange {
283        /// Requested width, in pixels.
284        width: u32,
285        /// Requested height, in pixels.
286        height: u32,
287        /// Smallest side either dimension may have.
288        min_side: u32,
289        /// Largest pixel count the two may multiply to.
290        max_pixels: u64,
291    },
292    /// No candidate texture passed the container gates.
293    NoUsableTexture {
294        /// Seeds that were tried.
295        candidates: u32,
296    },
297    /// Conditioned sampling could not reach a parity.
298    Sampling(RejectionExhausted),
299    /// Argon2id password stretching failed.
300    Kdf(KdfError),
301    /// HKDF-SHA3-512 expansion of the master key failed.
302    Expand(ExpandError),
303    /// Compression or encryption failed.
304    Crypto(CryptoError),
305    /// The container could not be written to disk.
306    Output(OutputError),
307}
308
309impl fmt::Display for GenerateError {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        match self {
312            GenerateError::Entropy(message) => write!(
313                f,
314                "could not read the system random number generator, and a container must not be \
315                 generated without it: {message}"
316            ),
317            GenerateError::PayloadTooLarge {
318                payload,
319                available,
320                deficit,
321                ..
322            } => write!(
323                f,
324                "the payload does not fit in the requested container: {payload} bytes after \
325                 compression against the {available} it admits, {deficit} bytes over"
326            ),
327            GenerateError::DimensionsOutOfRange {
328                width,
329                height,
330                min_side,
331                max_pixels,
332            } => write!(
333                f,
334                "the requested container is {width}x{height}, which is outside the permitted \
335                 range: each side must be at least {min_side} pixels and the two together at \
336                 most {max_pixels} pixels"
337            ),
338            GenerateError::NoUsableTexture { candidates } => write!(
339                f,
340                "no texture passed the container gates in {candidates} candidates"
341            ),
342            GenerateError::Sampling(err) => write!(f, "{err}"),
343            GenerateError::Kdf(err) => write!(f, "{err}"),
344            GenerateError::Expand(err) => write!(f, "{err}"),
345            GenerateError::Crypto(err) => write!(f, "{err}"),
346            GenerateError::Output(err) => write!(f, "{err}"),
347        }
348    }
349}
350
351impl std::error::Error for GenerateError {
352    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
353        match self {
354            GenerateError::Sampling(err) => Some(err),
355            GenerateError::Kdf(err) => Some(err),
356            GenerateError::Expand(err) => Some(err),
357            GenerateError::Crypto(err) => Some(err),
358            GenerateError::Output(err) => Some(err),
359            GenerateError::Entropy(_)
360            | GenerateError::PayloadTooLarge { .. }
361            | GenerateError::DimensionsOutOfRange { .. }
362            | GenerateError::NoUsableTexture { .. } => None,
363        }
364    }
365}
366
367impl From<RejectionExhausted> for GenerateError {
368    fn from(err: RejectionExhausted) -> Self {
369        GenerateError::Sampling(err)
370    }
371}
372
373impl From<KdfError> for GenerateError {
374    fn from(err: KdfError) -> Self {
375        GenerateError::Kdf(err)
376    }
377}
378
379impl From<ExpandError> for GenerateError {
380    fn from(err: ExpandError) -> Self {
381        GenerateError::Expand(err)
382    }
383}
384
385impl From<CryptoError> for GenerateError {
386    fn from(err: CryptoError) -> Self {
387        GenerateError::Crypto(err)
388    }
389}
390
391impl From<AEADError> for GenerateError {
392    fn from(err: AEADError) -> Self {
393        GenerateError::Crypto(CryptoError::AEADError(err))
394    }
395}
396
397impl From<OutputError> for GenerateError {
398    fn from(err: OutputError) -> Self {
399        GenerateError::Output(err)
400    }
401}
402
403/// The side of a comfortable square container for a payload this large.
404///
405/// The smallest square whose payload capacity clears `payload`, then rounded up
406/// to the next hundred pixels — a figure a person can read and repeat, with a
407/// little headroom over the exact break-even side rather than sitting right on
408/// it. `None` when even the largest permitted container is too small: that is
409/// the payload's problem and not a size the user can dial around.
410///
411/// The rounding never pushes the suggestion past [`MAX_CONTAINER_PIXELS`]; on
412/// the rare payload whose break-even side is within a hundred pixels of the
413/// ceiling, the exact side is quoted instead of a round one that would not fit.
414fn recommended_square_side(payload: usize) -> Option<u32> {
415    // capacity(side) = side * side * CHANNELS / 8 - overhead, and a `u8/8`
416    // capacity clears `payload` exactly when the sample count reaches
417    // `8 * (payload + overhead)`. Everything is taken in `u64`: the product of
418    // two sides is what the size gate guards against wrapping, and this is the
419    // same product read backwards.
420    let overhead = (TAG_BYTES + LENGTH_HEADER_BYTES) as u64;
421    let needed_bytes = (payload as u64).checked_add(overhead)?;
422    let needed_pixels = needed_bytes.checked_mul(8)?.div_ceil(CHANNELS as u64);
423
424    if needed_pixels > MAX_CONTAINER_PIXELS {
425        return None;
426    }
427
428    let exact_side = integer_sqrt_ceil(needed_pixels).max(MIN_CONTAINER_SIDE);
429    let rounded = exact_side.div_ceil(100).saturating_mul(100);
430
431    // The round figure unless it would spill over the pixel ceiling, in which
432    // case the exact break-even side — already known to fit — is quoted.
433    let side = if u64::from(rounded) * u64::from(rounded) <= MAX_CONTAINER_PIXELS {
434        rounded
435    } else {
436        exact_side
437    };
438
439    Some(side)
440}
441
442/// The smallest integer whose square is at least `value`.
443///
444/// A float square root corrected in both directions rather than trusted: the
445/// conversion is exact for the pixel counts this is called with — all below the
446/// megapixel ceiling — but the correction costs nothing and removes the last
447/// place a rounding error could quote a container one pixel too small.
448fn integer_sqrt_ceil(value: u64) -> u32 {
449    let mut root = (value as f64).sqrt() as u64;
450
451    while root.saturating_mul(root) < value {
452        root += 1;
453    }
454    while root > 0 && (root - 1).saturating_mul(root - 1) >= value {
455        root -= 1;
456    }
457
458    u32::try_from(root).unwrap_or(u32::MAX)
459}
460
461/// Builds a `dimensions` container around `plaintext` and writes it to
462/// `output_path`.
463///
464/// Both secrets are taken by value in a [`Zeroizing`] wrapper, as in
465/// [`crate::pipeline::EmbedPipeline::embed`]: this function becomes their owner
466/// and wipes them where they stop being needed.
467///
468/// `dimensions` is already validated — the only way to hold one is
469/// [`ContainerDimensions::new`] — so this function cannot be handed a size the
470/// loader would refuse. Pass [`ContainerDimensions::default`] for the historical
471/// square container when no particular size is wanted.
472///
473/// The container it writes does not hide that it was generated. It hides which
474/// of several generated containers carries a message; see the module
475/// documentation for the difference, which is the whole of what this mode
476/// promises.
477///
478/// # Errors
479///
480/// Returns a [`GenerateError`] when the system random number generator cannot
481/// be read, the compressed payload does not fit the requested size, no candidate
482/// texture passes the container gates, a cryptographic step fails, or the file
483/// cannot be written.
484pub fn generate_container(
485    plaintext: Zeroizing<Vec<u8>>,
486    password: Zeroizing<Vec<u8>>,
487    dimensions: ContainerDimensions,
488    output_path: &Path,
489) -> Result<GenerateReport, GenerateError> {
490    generate(
491        &Argon2Kdf::default_secure(),
492        plaintext,
493        password,
494        dimensions,
495        output_path,
496    )
497}
498
499/// [`generate_container`] with the key deriver injected.
500///
501/// Compiled only under `cfg(test)` or the `test-utils` feature, for the same
502/// reason [`Argon2Kdf::low_cost_for_tests`] is: a suite that paid 128 MiB and
503/// four hundred milliseconds per candidate would be a suite nobody runs. There
504/// is no public constructor that weakens the production path.
505///
506/// # Errors
507///
508/// As [`generate_container`].
509#[cfg(any(test, feature = "test-utils"))]
510pub fn generate_container_with_deriver(
511    kdf: &dyn KeyDeriver,
512    plaintext: Zeroizing<Vec<u8>>,
513    password: Zeroizing<Vec<u8>>,
514    dimensions: ContainerDimensions,
515    output_path: &Path,
516) -> Result<GenerateReport, GenerateError> {
517    generate(kdf, plaintext, password, dimensions, output_path)
518}
519
520/// The generator proper.
521///
522/// The sequence, per candidate texture, and why it is in this order:
523///
524/// 1. Draw the texture field from the CSPRNG.
525/// 2. Render a **draft** with grain drawn freely. Its only job is to fix the
526///    perceptual hash.
527/// 3. Put it through the gates a receiver's loader will apply. A refusal costs
528///    another candidate and nothing else.
529/// 4. Derive the key from the draft's hash: Argon2id, then HKDF.
530/// 5. Fill the container-sized buffer, and encrypt it.
531/// 6. Render the **final** container: the same field, grain conditioned on the
532///    ciphertext.
533/// 7. Check that it still hashes to what the draft hashed to. The margin makes
534///    this near-certain, but checking is cheap and its failure would be a
535///    container nobody can read.
536/// 8. Write the PNG.
537///
538/// The compression in step 5 is hoisted out of the loop: it does not depend on
539/// the key, and doing it once means a payload that cannot fit is refused before
540/// a single pixel is rendered rather than a minute later.
541fn generate(
542    kdf: &dyn KeyDeriver,
543    plaintext: Zeroizing<Vec<u8>>,
544    password: Zeroizing<Vec<u8>>,
545    dimensions: ContainerDimensions,
546    output_path: &Path,
547) -> Result<GenerateReport, GenerateError> {
548    let cipher = XChaCha20Poly1305Cipher::new();
549
550    // The message becomes its compressed form once, and the plaintext is
551    // dropped — and therefore wiped — at the earliest point the chain allows.
552    let compressed = compress(plaintext.as_slice())?;
553    drop(plaintext);
554
555    let available = dimensions.payload_capacity();
556    if compressed.len() > available {
557        return Err(GenerateError::PayloadTooLarge {
558            payload: compressed.len(),
559            available,
560            deficit: compressed.len() - available,
561            // A square suggestion even for a rectangular request: it is the one
562            // shape a single figure describes, and the user is free to spend it
563            // on whichever pair of sides they like.
564            recommended_side: recommended_square_side(compressed.len()),
565        });
566    }
567
568    let mut rng = seed_from_system()?;
569
570    for _ in 0..MAX_CANDIDATES {
571        let texture = Texture::new(rng.next_u64(), dimensions.width(), dimensions.height());
572
573        // Step 2 and 3. The draft exists only in memory, and only long enough
574        // to be judged: it is the cover, and the cover is the thing this mode
575        // exists to not leave lying around.
576        let draft = render(&texture, dimensions, &mut rng, None)?;
577        let Ok(draft_salt) = compute_stable_phash(&draft) else {
578            continue;
579        };
580        if !passes_container_gates(&draft) {
581            continue;
582        }
583        drop(draft);
584
585        // Steps 4 and 5. The password is borrowed rather than consumed: a
586        // candidate that fails at step 7 needs it again.
587        let master_key = kdf.derive(password.as_slice(), &draft_salt)?;
588        let derived_keys = expand_master_key(&master_key)?;
589        drop(master_key);
590
591        let ciphertext = seal(&compressed, dimensions, &mut rng, &derived_keys, &cipher)?;
592        drop(derived_keys);
593
594        // Steps 6 and 7.
595        let container = render(&texture, dimensions, &mut rng, Some(&ciphertext))?;
596        drop(ciphertext);
597
598        let Ok(final_salt) = compute_stable_phash(&container) else {
599            continue;
600        };
601        if final_salt.as_bytes() != draft_salt.as_bytes() || shows_jpeg_grid(&container) {
602            continue;
603        }
604
605        write_png(&container, output_path)?;
606
607        return Ok(GenerateReport {
608            image_dimensions: container.dimensions(),
609            payload_bytes: compressed.len(),
610            capacity_bytes: available,
611        });
612    }
613
614    Err(GenerateError::NoUsableTexture {
615        candidates: MAX_CANDIDATES,
616    })
617}
618
619/// A generator seeded with [`SEED_BYTES`] bytes from the system CSPRNG.
620///
621/// The seed is wiped as soon as the generator holds it. The generator's own
622/// state cannot be wiped from outside — `StdRng` exposes no way to reach it —
623/// which is why the seed is the thing that is guarded and why it is drawn from
624/// the operating system rather than from anything reproducible.
625///
626/// # Errors
627///
628/// Returns [`GenerateError::Entropy`] when the system generator cannot be read.
629/// There is no fallback on purpose.
630fn seed_from_system() -> Result<StdRng, GenerateError> {
631    let mut seed = Zeroizing::new([0u8; SEED_BYTES]);
632
633    SysRng
634        .try_fill_bytes(seed.as_mut_slice())
635        .map_err(|err| GenerateError::Entropy(err.to_string()))?;
636
637    let rng = StdRng::from_seed(*seed);
638    drop(seed);
639
640    Ok(rng)
641}
642
643/// Whether a candidate would survive the journey to a receiver.
644///
645/// The gates of layer 1 and of the cost layer, applied to a buffer that never
646/// went through a file. That is deliberate: [`crate::image_io::validate::load_and_validate`]
647/// is the only public way to obtain an [`ImageBuffer`], and it needs a path —
648/// but this code lives inside the crate, so it can build the buffer directly
649/// and hand it to the very same analyses. The perceptual hash is checked by the
650/// caller, which needs its value rather than its verdict.
651///
652/// The cost model has no part in the embedding here and is checked anyway: it
653/// is what `scan` runs, so a container that failed it would be one the tool
654/// itself reports as unusable.
655fn passes_container_gates(image: &ImageBuffer) -> bool {
656    !shows_jpeg_grid(image) && HillCostProvider::new().compute(image).is_ok()
657}
658
659/// Whether the block detector of layer 1 would read a JPEG grid in `image`.
660///
661/// Applied to the final container as well as to the draft, unlike the cost
662/// model: the detector samples blocks at random and it is the final container
663/// that will be handed to it, whereas the cost model measures the texture
664/// energy of a field the two share.
665fn shows_jpeg_grid(image: &ImageBuffer) -> bool {
666    let (width, height) = image.dimensions();
667
668    detect_jpeg_artifacts(image.pixels(), width, height, image.color_space()).is_some()
669}
670
671/// Renders one container.
672///
673/// With `carrier` present, the least significant bit of every sample is drawn
674/// to equal the corresponding ciphertext bit, most significant bit of each byte
675/// first. Samples past the end of the ciphertext are drawn freely, which for
676/// the geometry this mode uses is none of them: the ciphertext is sized to fill
677/// the container exactly.
678///
679/// # Errors
680///
681/// Returns [`GenerateError::Sampling`] if conditioned sampling fails to
682/// converge, which no base level this crate's texture produces can cause.
683fn render(
684    texture: &Texture,
685    dimensions: ContainerDimensions,
686    rng: &mut StdRng,
687    carrier: Option<&[u8]>,
688) -> Result<ImageBuffer, GenerateError> {
689    let (width, height) = (dimensions.width(), dimensions.height());
690    let mut samples = vec![0u8; width as usize * height as usize * CHANNELS];
691    let carrier_bits = carrier.map_or(0, |bytes| bytes.len() * 8);
692
693    let mut position = 0usize;
694    for y in 0..height {
695        for x in 0..width {
696            // Once per pixel rather than once per channel: the field is a
697            // property of the position, and the three channels are tints of it.
698            let base_levels = texture.base_levels(x, y);
699
700            for &base in base_levels.iter() {
701                let value = match carrier {
702                    Some(bytes) if position < carrier_bits => {
703                        // In range: `carrier_bits` is `bytes.len() * 8`.
704                        let byte = bytes.get(position / 8).copied().unwrap_or(0);
705                        let bit = (byte >> (7 - position % 8)) & 1;
706                        draw_with_lsb(rng, base, bit)?
707                    }
708                    _ => draw_free(rng, base),
709                };
710
711                if let Some(sample) = samples.get_mut(position) {
712                    *sample = value;
713                }
714                position += 1;
715            }
716        }
717    }
718
719    Ok(ImageBuffer::new(samples, width, height, ColorSpace::Rgb8))
720}
721
722/// Builds the buffer the container is filled with, and encrypts it.
723///
724/// The plaintext of that one encryption is the whole container:
725///
726/// ```text
727/// [u32 big-endian: compressed length][zstd(message)][random padding]
728/// ```
729///
730/// # Why it is filled to the last byte
731///
732/// Two properties, and neither is optional:
733///
734/// 1. **The receiver cannot derive the length from anything else.** Zstandard
735///    returns slightly *more* than it was given on incompressible input, so the
736///    compressed length is not a function of any quantity a receiver holds. It
737///    has to travel, and it travels inside the authenticated plaintext.
738/// 2. **Every container is the same size whatever it carries**, so the size of
739///    the message does not leak. A ciphertext cut to the exact length of the
740///    payload would leak it in full.
741///
742/// The padding is drawn from the CSPRNG rather than left as zeros. It is
743/// encrypted either way, but padding with structure is a temptation with no
744/// upside.
745///
746/// # Errors
747///
748/// Returns [`GenerateError::Crypto`] if the cipher refuses the buffer.
749fn seal(
750    compressed: &[u8],
751    dimensions: ContainerDimensions,
752    rng: &mut StdRng,
753    keys: &DerivedKeys,
754    cipher: &dyn AEADCipher,
755) -> Result<Zeroizing<Vec<u8>>, GenerateError> {
756    let plaintext_len = dimensions.capacity().saturating_sub(TAG_BYTES);
757
758    let mut buffer = Zeroizing::new(Vec::with_capacity(plaintext_len));
759    // Checked against `payload_capacity` by the caller, so the conversion holds
760    // for any container geometry this crate can build.
761    let announced = u32::try_from(compressed.len()).unwrap_or(u32::MAX);
762    buffer.extend_from_slice(&announced.to_be_bytes());
763    buffer.extend_from_slice(compressed);
764
765    let filled = buffer.len();
766    buffer.resize(plaintext_len, 0);
767    if let Some(padding) = buffer.get_mut(filled..) {
768        rng.fill_bytes(padding);
769    }
770
771    let ciphertext = cipher.encrypt(keys.enc_key(), keys.nonce(), &buffer, STENOXIDE_AAD)?;
772    drop(buffer);
773
774    Ok(ciphertext)
775}
776
777/// Reads the payload out of a container that was generated around it.
778///
779/// The counterpart of [`generate`], and the second of the two readings
780/// [`crate::pipeline::EmbedPipeline::extract`] tries. It needs no cost map, no
781/// permutation and no trellis: the ciphertext is the least significant bit of
782/// every sample, in raster order, and it fills the container exactly.
783///
784/// Returns the recovered message and the ciphertext bytes it was read from.
785///
786/// # Errors
787///
788/// Returns a [`CryptoError`] when the container was not generated around a
789/// payload, when it was generated under a different key, or when the
790/// authenticated buffer does not hold a payload of the length it announces.
791/// The caller must not distinguish these from each other, or from the failure
792/// of the other reading: that is the whole reason both are attempted.
793pub(crate) fn read_generated(
794    image: &ImageBuffer,
795    keys: &DerivedKeys,
796    cipher: &dyn AEADCipher,
797) -> Result<(Zeroizing<Vec<u8>>, usize), CryptoError> {
798    let samples = image.pixels();
799    let capacity = samples.len() / 8;
800
801    if capacity <= TAG_BYTES + LENGTH_HEADER_BYTES {
802        return Err(CryptoError::AEADError(AEADError::AuthenticationFailed));
803    }
804
805    let ciphertext = Zeroizing::new(gather_carrier_bits(samples, capacity));
806    let buffer = cipher.decrypt(keys.enc_key(), keys.nonce(), &ciphertext, STENOXIDE_AAD)?;
807
808    // Past this line the tag has vouched for every byte, so a malformed header
809    // is damage rather than a wrong key — the same distinction the embedding
810    // path draws between authentication and decompression.
811    let Some(header) = buffer.get(..LENGTH_HEADER_BYTES) else {
812        return Err(CryptoError::DecompressionError(
813            "the authenticated buffer is shorter than its own length header".to_owned(),
814        ));
815    };
816    let announced = header
817        .try_into()
818        .map(|bytes: [u8; LENGTH_HEADER_BYTES]| u32::from_be_bytes(bytes) as usize)
819        .unwrap_or(0);
820
821    let Some(body) = buffer.get(LENGTH_HEADER_BYTES..LENGTH_HEADER_BYTES + announced) else {
822        return Err(CryptoError::DecompressionError(
823            "the authenticated buffer announces more payload than it holds".to_owned(),
824        ));
825    };
826
827    let plaintext = decompress(body)?;
828    drop(buffer);
829
830    Ok((plaintext, capacity))
831}
832
833/// Collects the least significant bit of the first `bytes * 8` samples.
834///
835/// Most significant bit of each output byte first, which is the order
836/// [`render`] writes them in.
837fn gather_carrier_bits(samples: &[u8], bytes: usize) -> Vec<u8> {
838    let mut out = vec![0u8; bytes];
839
840    for (position, sample) in samples.iter().enumerate().take(bytes * 8) {
841        if let Some(byte) = out.get_mut(position / 8) {
842            *byte |= (sample & 1) << (7 - position % 8);
843        }
844    }
845
846    out
847}
848
849#[cfg(test)]
850mod tests {
851    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
852    // well. A test that cannot panic cannot fail, so they are lifted here and
853    // only here.
854    #![allow(clippy::expect_used)]
855    #![allow(clippy::panic)]
856
857    use super::*;
858
859    use crate::crypto::kdf::MasterKey;
860
861    /// Keys that are not derived from any container, for the buffer-level tests
862    /// below. Nothing here is about the derivation.
863    fn keys() -> DerivedKeys {
864        expand_master_key(&MasterKey::new([0x3Cu8; 32])).expect("expansion must succeed")
865    }
866
867    /// The container is filled to the last sample it can carry.
868    #[test]
869    fn the_ciphertext_is_sized_to_the_container() {
870        let default = ContainerDimensions::default();
871        let samples = default.width() as usize * default.height() as usize * CHANNELS;
872
873        assert_eq!(default.capacity(), samples / 8);
874        assert_eq!(default.capacity(), 1_500_000);
875        assert_eq!(
876            default.payload_capacity(),
877            1_500_000 - TAG_BYTES - LENGTH_HEADER_BYTES
878        );
879    }
880
881    /// Capacity is a straight function of the pixel count, square or not.
882    ///
883    /// The whole reason a larger container fits a larger payload: every sample
884    /// carries one bit, so the admitted payload grows with `width * height` and
885    /// a rectangle admits exactly what a square of the same area does.
886    #[test]
887    fn capacity_follows_the_pixel_count() {
888        let square = ContainerDimensions::new(4000, 4000).expect("within range");
889        let rectangle = ContainerDimensions::new(2000, 8000).expect("within range");
890
891        assert_eq!(square.capacity(), 4000 * 4000 * CHANNELS / 8);
892        assert_eq!(square.capacity(), rectangle.capacity());
893        assert!(square.capacity() > ContainerDimensions::default().capacity());
894    }
895
896    /// The size gates refuse a side below the floor and a product above the cap.
897    #[test]
898    fn dimensions_are_held_to_both_gates() {
899        assert!(ContainerDimensions::new(MIN_CONTAINER_SIDE, MIN_CONTAINER_SIDE).is_ok());
900
901        let too_short = ContainerDimensions::new(MIN_CONTAINER_SIDE - 1, MIN_CONTAINER_SIDE)
902            .map(|_| ())
903            .expect_err("a side below the floor must be refused");
904        assert!(matches!(
905            too_short,
906            GenerateError::DimensionsOutOfRange { .. }
907        ));
908
909        // A width that alone is fine but multiplies past the ceiling.
910        let widest = (MAX_CONTAINER_PIXELS / u64::from(MIN_CONTAINER_SIDE)) as u32;
911        assert!(ContainerDimensions::new(widest, MIN_CONTAINER_SIDE).is_ok());
912        let over = ContainerDimensions::new(widest + 100, MIN_CONTAINER_SIDE)
913            .map(|_| ())
914            .expect_err("a product above the ceiling must be refused");
915        assert!(matches!(over, GenerateError::DimensionsOutOfRange { .. }));
916    }
917
918    /// The recommended side clears the payload, rounds to a hundred, and gives
919    /// up only when no permitted container could hold it.
920    #[test]
921    fn the_recommended_side_is_round_and_sufficient() {
922        // The figure from the user report: about 1.78 MB compressed.
923        let side = recommended_square_side(1_782_778).expect("a container this size exists");
924        assert_eq!(side % 100, 0, "the suggestion must be a round figure");
925        assert!(side >= MIN_CONTAINER_SIDE);
926
927        let admitted = ContainerDimensions::new(side, side)
928            .expect("the suggestion must be within range")
929            .payload_capacity();
930        assert!(
931            admitted >= 1_782_778,
932            "a container of the suggested side must actually hold the payload"
933        );
934        // And it is not wildly oversized: the previous hundred would not do.
935        let admitted_below = ContainerDimensions::new(side - 100, side - 100)
936            .expect("within range")
937            .payload_capacity();
938        assert!(admitted_below < 1_782_778);
939
940        // A payload no permitted container can hold has no suggestion to make.
941        let unattainable = (MAX_CONTAINER_PIXELS as usize) * CHANNELS / 8;
942        assert!(recommended_square_side(unattainable).is_none());
943    }
944
945    /// The carrier bits are written and read in the same order.
946    #[test]
947    fn the_carrier_round_trips_through_the_samples() {
948        let payload = [0b1010_1010u8, 0b0000_1111, 0xFF, 0x00];
949
950        // One sample per bit, carrying nothing but that bit.
951        let samples: Vec<u8> = (0..payload.len() * 8)
952            .map(|position| {
953                let byte = payload[position / 8];
954                (byte >> (7 - position % 8)) & 1
955            })
956            .collect();
957
958        assert_eq!(gather_carrier_bits(&samples, payload.len()), payload);
959
960        // The high bits of a sample are not part of the carrier.
961        let noisy: Vec<u8> = samples.iter().map(|bit| bit | 0xF0).collect();
962        assert_eq!(gather_carrier_bits(&noisy, payload.len()), payload);
963    }
964
965    /// The sealed buffer occupies the whole container, whatever it carries.
966    ///
967    /// The property that keeps the message size from leaking: a one-byte
968    /// payload and a large one produce ciphertexts of exactly the same length.
969    #[test]
970    fn every_sealed_buffer_is_the_same_size() {
971        let mut rng = StdRng::seed_from_u64(5);
972        let cipher = XChaCha20Poly1305Cipher::new();
973        let keys = keys();
974        let dimensions = ContainerDimensions::default();
975
976        for length in [0usize, 1, 4_096, 100_000] {
977            let compressed = vec![0x5Au8; length];
978            let sealed = seal(&compressed, dimensions, &mut rng, &keys, &cipher)
979                .expect("a payload within capacity must seal");
980
981            assert_eq!(sealed.len(), dimensions.capacity(), "payload of {length}");
982        }
983    }
984
985    /// A sealed buffer reads back through the container-shaped reader.
986    ///
987    /// Driven without rendering an image: the samples are synthesised from the
988    /// ciphertext, which is exactly what a rendered container's least
989    /// significant bits are.
990    #[test]
991    fn a_sealed_payload_is_recovered_by_the_reader() {
992        let mut rng = StdRng::seed_from_u64(9);
993        let cipher = XChaCha20Poly1305Cipher::new();
994        let keys = keys();
995
996        let dimensions = ContainerDimensions::default();
997        let message = b"a message that is compressed, sealed and read back".repeat(4);
998        let compressed = compress(&message).expect("compression must succeed");
999        let sealed = seal(&compressed, dimensions, &mut rng, &keys, &cipher)
1000            .expect("sealing must succeed");
1001
1002        let samples: Vec<u8> = (0..sealed.len() * 8)
1003            .map(|position| {
1004                let byte = sealed.get(position / 8).copied().unwrap_or(0);
1005                0x80 | ((byte >> (7 - position % 8)) & 1)
1006            })
1007            .collect();
1008        let image = ImageBuffer::new(
1009            samples,
1010            dimensions.width(),
1011            dimensions.height(),
1012            ColorSpace::Rgb8,
1013        );
1014
1015        match read_generated(&image, &keys, &cipher) {
1016            Ok((plaintext, bytes)) => {
1017                assert_eq!(plaintext.as_slice(), message.as_slice());
1018                assert_eq!(bytes, dimensions.capacity());
1019            }
1020            Err(error) => panic!("a sealed payload must be recovered: {error}"),
1021        }
1022
1023        // Any other key is an authentication failure, and says nothing more.
1024        let other = expand_master_key(&MasterKey::new([0x11u8; 32])).expect("expansion");
1025        let error = read_generated(&image, &other, &cipher)
1026            .map(|_| ())
1027            .expect_err("a wrong key must not authenticate");
1028        assert!(
1029            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1030            "got: {error:?}"
1031        );
1032    }
1033
1034    /// A container too small to hold a header is refused as an authentication
1035    /// failure, like everything else this reader can refuse.
1036    #[test]
1037    fn a_container_without_room_for_a_payload_is_refused() {
1038        let image = ImageBuffer::new(vec![0u8; 64], 4, 4, ColorSpace::Rgb8);
1039        let error = read_generated(&image, &keys(), &XChaCha20Poly1305Cipher::new())
1040            .map(|_| ())
1041            .expect_err("a container with no room must be refused");
1042
1043        assert!(
1044            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1045            "got: {error:?}"
1046        );
1047    }
1048
1049    /// Every failure explains itself, and the chain of causes is wired.
1050    #[test]
1051    fn every_failure_explains_itself() {
1052        let messages = [
1053            GenerateError::Entropy("no device".to_owned()).to_string(),
1054            GenerateError::PayloadTooLarge {
1055                payload: 2_000_000,
1056                available: 1_499_980,
1057                deficit: 500_020,
1058                recommended_side: recommended_square_side(2_000_000),
1059            }
1060            .to_string(),
1061            GenerateError::DimensionsOutOfRange {
1062                width: 1_000,
1063                height: 3_000,
1064                min_side: MIN_CONTAINER_SIDE,
1065                max_pixels: MAX_CONTAINER_PIXELS,
1066            }
1067            .to_string(),
1068            GenerateError::NoUsableTexture { candidates: 64 }.to_string(),
1069            GenerateError::Sampling(RejectionExhausted).to_string(),
1070            GenerateError::from(KdfError::EmptyPassword).to_string(),
1071            GenerateError::from(ExpandError::HkdfError("too long".to_owned())).to_string(),
1072            GenerateError::from(AEADError::AuthenticationFailed).to_string(),
1073            GenerateError::from(OutputError::MalformedBuffer).to_string(),
1074        ];
1075
1076        for message in &messages {
1077            assert!(!message.is_empty());
1078        }
1079
1080        assert!(messages[0].contains("no device"));
1081        assert!(messages[1].contains("2000000") && messages[1].contains("500020"));
1082        assert!(messages[2].contains("1000x3000") && messages[2].contains("2000"));
1083        assert!(messages[3].contains("64"));
1084
1085        // Only the variants that wrap another error have a cause to chain to.
1086        assert!(std::error::Error::source(&GenerateError::from(KdfError::EmptyPassword)).is_some());
1087        assert!(
1088            std::error::Error::source(&GenerateError::NoUsableTexture { candidates: 1 }).is_none()
1089        );
1090        assert!(std::error::Error::source(&GenerateError::DimensionsOutOfRange {
1091            width: 1_000,
1092            height: 3_000,
1093            min_side: MIN_CONTAINER_SIDE,
1094            max_pixels: MAX_CONTAINER_PIXELS,
1095        })
1096        .is_none());
1097    }
1098
1099    /// The seed comes from the system generator, and it produces a working one.
1100    #[test]
1101    fn the_generator_is_seeded_from_the_system() {
1102        let mut first = seed_from_system().expect("the system generator must be readable");
1103        let mut second = seed_from_system().expect("the system generator must be readable");
1104
1105        // Two draws that agreed would mean the seed was not what it claims.
1106        assert_ne!(first.next_u64(), second.next_u64());
1107    }
1108}