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//! # Building for a recipient instead of for a password
72//!
73//! Behind the `pqc` feature, [`generate_container_for_recipient`] builds the
74//! same container against an ML-KEM-1024 public key, with no password on either
75//! side. It is **experimental**: the layout it writes is not a settled format
76//! and no default build offers it. Compile it in with
77//! `cargo build --features pqc` on this crate, or `--features pqc` on
78//! `stenoxide-cli`, which forwards it.
79//!
80//! Two things change and nothing else does:
81//!
82//! - **The key does not come from the container.** The sender draws a fresh
83//!   secret, encapsulates it to the recipient's public key, and derives the
84//!   message keys from that. Nothing is stretched, nothing is hashed from the
85//!   image, and the whole candidate search happens *after* the key already
86//!   exists — no draft is rendered, because a draft exists only to pin down a
87//!   perceptual hash nothing is derived from here.
88//! - **1568 bytes of the container are spent on the encapsulation**, which
89//!   travels at the head of the carrier so that a receiver can read it knowing
90//!   nothing. The capacity the caller is told, and the capacity a payload is
91//!   judged against, are both lower by exactly that much.
92//!
93//! Placing the encapsulation at a position anyone can compute costs nothing in
94//! this mode, and it is worth being precise about why: no sample is modified,
95//! so there is no change whose density an analyst could measure over the region
96//! they have located. The same layout in a container a payload was *embedded*
97//! into would hand a steganalyst a known set of positions to aim a targeted
98//! test at, which is a real cost and a different problem.
99//!
100//! # The seed is key material
101//!
102//! There is no cover to subtract, but an adversary who can reproduce the
103//! generator's random state can regenerate the container and read the
104//! difference — and the confirmation is unmistakable, because the right state
105//! reproduces the image and a wrong one differs in millions of samples. So the
106//! generator is seeded with 32 bytes from the system CSPRNG and from nothing
107//! else: never a timestamp, never a counter, never anything derived from the
108//! password. The seed is not persisted, printed or logged anywhere.
109
110mod carrier;
111mod texture;
112
113use std::fmt;
114use std::path::Path;
115
116use rand::rngs::{StdRng, SysRng};
117use rand::{Rng, SeedableRng, TryRng};
118use zeroize::Zeroizing;
119
120use crate::cost::hill::HillCostProvider;
121use crate::cost::CostProvider;
122use crate::crypto::aead::{
123    compress, decompress, AEADCipher, AEADError, CryptoError, XChaCha20Poly1305Cipher,
124    STENOXIDE_AAD,
125};
126use crate::crypto::expand::{expand_master_key, DerivedKeys, ExpandError};
127#[cfg(feature = "pqc")]
128use crate::crypto::kem::{KemError, RecipientKey};
129use crate::crypto::kdf::{Argon2Kdf, KdfError, KeyDeriver};
130use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
131use crate::image_io::jpeg_detect::detect_jpeg_artifacts;
132use crate::image_io::phash::compute_stable_phash;
133use crate::image_io::validate::{MAX_PIXELS, MIN_DIMENSION};
134use crate::pipeline::error::OutputError;
135use crate::pipeline::frame::write_png;
136use crate::stego::sizer::EmbeddingMode;
137
138use self::carrier::{draw_free, draw_with_lsb};
139use self::texture::Texture;
140
141pub use self::carrier::RejectionExhausted;
142
143/// Smallest side a generated container may have, in pixels.
144///
145/// Exactly the floor [`crate::image_io::validate`] applies to a container read
146/// from disk: a container this mode draws has to be one that mode would accept
147/// back, so the two share the number rather than each naming their own. It is
148/// also, for the texture, the smallest side whose cell scale the perceptual-hash
149/// gate reliably accepts — the reason the side used to be fixed here.
150pub const MIN_CONTAINER_SIDE: u32 = MIN_DIMENSION;
151
152/// Largest pixel count a generated container may have.
153///
154/// The same ceiling the loader refuses above, and for the same reason: a
155/// receiver has to analyse whatever a sender draws, and that analysis costs
156/// memory linear in the pixel count. A container the sender could draw but the
157/// receiver could not load would be useless to both.
158pub const MAX_CONTAINER_PIXELS: u64 = MAX_PIXELS;
159
160/// Side of the square container generated when no size is requested.
161///
162/// The historical default, kept as the behaviour of the size-less call: it is
163/// the minimum, so it is the smallest — and therefore least conspicuous — file
164/// the mode will produce.
165pub const DEFAULT_CONTAINER_SIDE: u32 = MIN_CONTAINER_SIDE;
166
167/// Channels of a generated container. It is written as 8-bit RGB.
168const CHANNELS: usize = 3;
169
170/// Bytes of the Poly1305 tag that rides at the end of the ciphertext.
171const TAG_BYTES: usize = 16;
172
173/// Bytes of the length header at the head of the encrypted buffer.
174///
175/// A big-endian `u32` counting the compressed payload that follows it.
176const LENGTH_HEADER_BYTES: usize = 4;
177
178/// Texture seeds tried before the attempt is abandoned.
179///
180/// A field passes the gates at something between two and four seeds in six, so
181/// sixty-four candidates turn acceptance into a certainty: `0.67^64` is about
182/// `1e-11`.
183const MAX_CANDIDATES: u32 = 64;
184
185/// Bytes of the seed the generator is started from.
186const SEED_BYTES: usize = 32;
187
188/// The mode a container built for a recipient's public key is sized in.
189///
190/// Named once so that the generator, the reader and the capacity check cannot
191/// drift apart, and so that the number of bytes key transport costs is asked of
192/// the sizer in exactly one place.
193#[cfg(feature = "pqc")]
194const ASYMMETRIC_MODE: EmbeddingMode = EmbeddingMode::AsymmetricPqc;
195
196/// The size of the container to draw, checked against the two size gates.
197///
198/// A validated pair rather than two loose integers: the only way to obtain one
199/// is [`ContainerDimensions::new`], which refuses anything the loader would
200/// refuse, so no code downstream has to re-check a width or a height. A larger
201/// container carries more — capacity is a straight function of its pixel count —
202/// but every size this type admits is one a receiver can load and one whose
203/// texture feeds the hash gate the same octave the default does; see
204/// [`crate::generate::texture`] for why enlarging is safe rather than merely
205/// tolerated.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub struct ContainerDimensions {
208    /// Width, in pixels. At least [`MIN_CONTAINER_SIDE`].
209    width: u32,
210    /// Height, in pixels. At least [`MIN_CONTAINER_SIDE`].
211    height: u32,
212}
213
214impl ContainerDimensions {
215    /// A dimensions pair, if it clears both gates a loaded container is held to.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`GenerateError::DimensionsOutOfRange`] when either side is below
220    /// [`MIN_CONTAINER_SIDE`], or when the two multiply to more than
221    /// [`MAX_CONTAINER_PIXELS`]. The product is taken in [`u64`] so that two
222    /// large sides cannot wrap into a small count and slip past the ceiling.
223    pub fn new(width: u32, height: u32) -> Result<Self, GenerateError> {
224        let out_of_range = || GenerateError::DimensionsOutOfRange {
225            width,
226            height,
227            min_side: MIN_CONTAINER_SIDE,
228            max_pixels: MAX_CONTAINER_PIXELS,
229        };
230
231        if width < MIN_CONTAINER_SIDE || height < MIN_CONTAINER_SIDE {
232            return Err(out_of_range());
233        }
234        if u64::from(width) * u64::from(height) > MAX_CONTAINER_PIXELS {
235            return Err(out_of_range());
236        }
237
238        Ok(Self { width, height })
239    }
240
241    /// Width of the container, in pixels.
242    pub fn width(&self) -> u32 {
243        self.width
244    }
245
246    /// Height of the container, in pixels.
247    pub fn height(&self) -> u32 {
248        self.height
249    }
250
251    /// Carrier bytes a container of this size holds.
252    ///
253    /// One bit per sample: the carrier occupies the container exactly, to the
254    /// last sample it can fill. Everything that travels is counted here — the
255    /// ciphertext, its tag, and whatever the mode spends on getting the key to
256    /// the recipient.
257    fn capacity(self) -> usize {
258        self.width as usize * self.height as usize * CHANNELS / 8
259    }
260
261    /// Compressed payload bytes a container of this size admits in `mode`.
262    ///
263    /// What is left of the carrier once the authentication tag, the length
264    /// header and the mode's key transport are paid for. The key-transport
265    /// figure is asked of [`EmbeddingMode`] rather than restated here: the
266    /// sizer is where that number is defined, and a second copy of it would be
267    /// a second thing to keep in step.
268    fn payload_capacity(self, mode: EmbeddingMode) -> usize {
269        self.capacity()
270            .saturating_sub(mode.key_transport_overhead_bytes())
271            .saturating_sub(TAG_BYTES + LENGTH_HEADER_BYTES)
272    }
273}
274
275impl Default for ContainerDimensions {
276    /// The square container the size-less call produces; see
277    /// [`DEFAULT_CONTAINER_SIDE`].
278    fn default() -> Self {
279        Self {
280            width: DEFAULT_CONTAINER_SIDE,
281            height: DEFAULT_CONTAINER_SIDE,
282        }
283    }
284}
285
286/// What one generation produced.
287///
288/// None of these figures travels with the container, and none of them is a
289/// secret the caller does not already hold: the container is always the same
290/// size whatever it carries, which is the point of filling it.
291#[derive(Debug)]
292pub struct GenerateReport {
293    /// Dimensions of the container as `(width, height)`, in pixels.
294    pub image_dimensions: (u32, u32),
295    /// Compressed payload bytes the container was built around.
296    ///
297    /// The message after Zstandard, not its length: the plaintext length is not
298    /// something the container carries, and reporting it here would suggest
299    /// otherwise.
300    pub payload_bytes: usize,
301    /// Compressed payload bytes a container of this size admits.
302    pub capacity_bytes: usize,
303}
304
305/// Everything that can go wrong between a plaintext and a generated container.
306#[derive(Debug)]
307pub enum GenerateError {
308    /// The system random number generator could not be read.
309    ///
310    /// Fatal rather than papered over: every alternative source of a seed is
311    /// one an adversary can reproduce, and a container generated from a
312    /// guessable seed is one they can regenerate and compare against.
313    Entropy(String),
314    /// The compressed payload is larger than the requested container can hold.
315    PayloadTooLarge {
316        /// Payload bytes after compression.
317        payload: usize,
318        /// Compressed payload bytes the requested container admits.
319        available: usize,
320        /// How far over the limit the payload is, in bytes.
321        deficit: usize,
322        /// Side of the smallest square container that would admit this payload,
323        /// rounded up to a round figure for quoting to a user, or `None` when
324        /// no permitted container is large enough. A caller with a size to
325        /// suggest reads it from here rather than solving the quadratic itself.
326        recommended_side: Option<u32>,
327    },
328    /// The requested container size is outside the permitted range.
329    DimensionsOutOfRange {
330        /// Requested width, in pixels.
331        width: u32,
332        /// Requested height, in pixels.
333        height: u32,
334        /// Smallest side either dimension may have.
335        min_side: u32,
336        /// Largest pixel count the two may multiply to.
337        max_pixels: u64,
338    },
339    /// No candidate texture passed the container gates.
340    NoUsableTexture {
341        /// Seeds that were tried.
342        candidates: u32,
343    },
344    /// Conditioned sampling could not reach a parity.
345    Sampling(RejectionExhausted),
346    /// Argon2id password stretching failed.
347    Kdf(KdfError),
348    /// HKDF-SHA3-512 expansion of the master key failed.
349    Expand(ExpandError),
350    /// Encapsulation to the recipient's public key failed.
351    #[cfg(feature = "pqc")]
352    Kem(KemError),
353    /// Compression or encryption failed.
354    Crypto(CryptoError),
355    /// The container could not be written to disk.
356    Output(OutputError),
357}
358
359impl fmt::Display for GenerateError {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        match self {
362            GenerateError::Entropy(message) => write!(
363                f,
364                "could not read the system random number generator, and a container must not be \
365                 generated without it: {message}"
366            ),
367            GenerateError::PayloadTooLarge {
368                payload,
369                available,
370                deficit,
371                ..
372            } => write!(
373                f,
374                "the payload does not fit in the requested container: {payload} bytes after \
375                 compression against the {available} it admits, {deficit} bytes over"
376            ),
377            GenerateError::DimensionsOutOfRange {
378                width,
379                height,
380                min_side,
381                max_pixels,
382            } => write!(
383                f,
384                "the requested container is {width}x{height}, which is outside the permitted \
385                 range: each side must be at least {min_side} pixels and the two together at \
386                 most {max_pixels} pixels"
387            ),
388            GenerateError::NoUsableTexture { candidates } => write!(
389                f,
390                "no texture passed the container gates in {candidates} candidates"
391            ),
392            GenerateError::Sampling(err) => write!(f, "{err}"),
393            GenerateError::Kdf(err) => write!(f, "{err}"),
394            GenerateError::Expand(err) => write!(f, "{err}"),
395            #[cfg(feature = "pqc")]
396            GenerateError::Kem(err) => write!(f, "{err}"),
397            GenerateError::Crypto(err) => write!(f, "{err}"),
398            GenerateError::Output(err) => write!(f, "{err}"),
399        }
400    }
401}
402
403impl std::error::Error for GenerateError {
404    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
405        match self {
406            GenerateError::Sampling(err) => Some(err),
407            GenerateError::Kdf(err) => Some(err),
408            GenerateError::Expand(err) => Some(err),
409            #[cfg(feature = "pqc")]
410            GenerateError::Kem(err) => Some(err),
411            GenerateError::Crypto(err) => Some(err),
412            GenerateError::Output(err) => Some(err),
413            GenerateError::Entropy(_)
414            | GenerateError::PayloadTooLarge { .. }
415            | GenerateError::DimensionsOutOfRange { .. }
416            | GenerateError::NoUsableTexture { .. } => None,
417        }
418    }
419}
420
421impl From<RejectionExhausted> for GenerateError {
422    fn from(err: RejectionExhausted) -> Self {
423        GenerateError::Sampling(err)
424    }
425}
426
427impl From<KdfError> for GenerateError {
428    fn from(err: KdfError) -> Self {
429        GenerateError::Kdf(err)
430    }
431}
432
433impl From<ExpandError> for GenerateError {
434    fn from(err: ExpandError) -> Self {
435        GenerateError::Expand(err)
436    }
437}
438
439#[cfg(feature = "pqc")]
440impl From<KemError> for GenerateError {
441    fn from(err: KemError) -> Self {
442        GenerateError::Kem(err)
443    }
444}
445
446impl From<CryptoError> for GenerateError {
447    fn from(err: CryptoError) -> Self {
448        GenerateError::Crypto(err)
449    }
450}
451
452impl From<AEADError> for GenerateError {
453    fn from(err: AEADError) -> Self {
454        GenerateError::Crypto(CryptoError::AEADError(err))
455    }
456}
457
458impl From<OutputError> for GenerateError {
459    fn from(err: OutputError) -> Self {
460        GenerateError::Output(err)
461    }
462}
463
464/// The side of a comfortable square container for a payload this large, in
465/// `mode`.
466///
467/// The smallest square whose payload capacity clears `payload`, then rounded up
468/// to the next hundred pixels — a figure a person can read and repeat, with a
469/// little headroom over the exact break-even side rather than sitting right on
470/// it. `None` when even the largest permitted container is too small: that is
471/// the payload's problem and not a size the user can dial around.
472///
473/// The mode is taken because it changes the overhead, and a suggestion that
474/// ignored it would name a container the very next attempt refuses.
475///
476/// The rounding never pushes the suggestion past [`MAX_CONTAINER_PIXELS`]; on
477/// the rare payload whose break-even side is within a hundred pixels of the
478/// ceiling, the exact side is quoted instead of a round one that would not fit.
479fn recommended_square_side(payload: usize, mode: EmbeddingMode) -> Option<u32> {
480    // capacity(side) = side * side * CHANNELS / 8 - overhead, and a `u8/8`
481    // capacity clears `payload` exactly when the sample count reaches
482    // `8 * (payload + overhead)`. Everything is taken in `u64`: the product of
483    // two sides is what the size gate guards against wrapping, and this is the
484    // same product read backwards.
485    let overhead = (TAG_BYTES + LENGTH_HEADER_BYTES + mode.key_transport_overhead_bytes()) as u64;
486    let needed_bytes = (payload as u64).checked_add(overhead)?;
487    let needed_pixels = needed_bytes.checked_mul(8)?.div_ceil(CHANNELS as u64);
488
489    if needed_pixels > MAX_CONTAINER_PIXELS {
490        return None;
491    }
492
493    let exact_side = integer_sqrt_ceil(needed_pixels).max(MIN_CONTAINER_SIDE);
494    let rounded = exact_side.div_ceil(100).saturating_mul(100);
495
496    // The round figure unless it would spill over the pixel ceiling, in which
497    // case the exact break-even side — already known to fit — is quoted.
498    let side = if u64::from(rounded) * u64::from(rounded) <= MAX_CONTAINER_PIXELS {
499        rounded
500    } else {
501        exact_side
502    };
503
504    Some(side)
505}
506
507/// The smallest integer whose square is at least `value`.
508///
509/// A float square root corrected in both directions rather than trusted: the
510/// conversion is exact for the pixel counts this is called with — all below the
511/// megapixel ceiling — but the correction costs nothing and removes the last
512/// place a rounding error could quote a container one pixel too small.
513fn integer_sqrt_ceil(value: u64) -> u32 {
514    let mut root = (value as f64).sqrt() as u64;
515
516    while root.saturating_mul(root) < value {
517        root += 1;
518    }
519    while root > 0 && (root - 1).saturating_mul(root - 1) >= value {
520        root -= 1;
521    }
522
523    u32::try_from(root).unwrap_or(u32::MAX)
524}
525
526/// Builds a `dimensions` container around `plaintext` and writes it to
527/// `output_path`.
528///
529/// Both secrets are taken by value in a [`Zeroizing`] wrapper, as in
530/// [`crate::pipeline::EmbedPipeline::embed`]: this function becomes their owner
531/// and wipes them where they stop being needed.
532///
533/// `dimensions` is already validated — the only way to hold one is
534/// [`ContainerDimensions::new`] — so this function cannot be handed a size the
535/// loader would refuse. Pass [`ContainerDimensions::default`] for the historical
536/// square container when no particular size is wanted.
537///
538/// The container it writes does not hide that it was generated. It hides which
539/// of several generated containers carries a message; see the module
540/// documentation for the difference, which is the whole of what this mode
541/// promises.
542///
543/// # Errors
544///
545/// Returns a [`GenerateError`] when the system random number generator cannot
546/// be read, the compressed payload does not fit the requested size, no candidate
547/// texture passes the container gates, a cryptographic step fails, or the file
548/// cannot be written.
549pub fn generate_container(
550    plaintext: Zeroizing<Vec<u8>>,
551    password: Zeroizing<Vec<u8>>,
552    dimensions: ContainerDimensions,
553    output_path: &Path,
554) -> Result<GenerateReport, GenerateError> {
555    generate(
556        &Argon2Kdf::default_secure(),
557        plaintext,
558        password,
559        dimensions,
560        output_path,
561    )
562}
563
564/// [`generate_container`] with the key deriver injected.
565///
566/// Compiled only under `cfg(test)` or the `test-utils` feature, for the same
567/// reason [`Argon2Kdf::low_cost_for_tests`] is: a suite that paid 128 MiB and
568/// four hundred milliseconds per candidate would be a suite nobody runs. There
569/// is no public constructor that weakens the production path.
570///
571/// # Errors
572///
573/// As [`generate_container`].
574#[cfg(any(test, feature = "test-utils"))]
575pub fn generate_container_with_deriver(
576    kdf: &dyn KeyDeriver,
577    plaintext: Zeroizing<Vec<u8>>,
578    password: Zeroizing<Vec<u8>>,
579    dimensions: ContainerDimensions,
580    output_path: &Path,
581) -> Result<GenerateReport, GenerateError> {
582    generate(kdf, plaintext, password, dimensions, output_path)
583}
584
585/// The generator proper.
586///
587/// The sequence, per candidate texture, and why it is in this order:
588///
589/// 1. Draw the texture field from the CSPRNG.
590/// 2. Render a **draft** with grain drawn freely. Its only job is to fix the
591///    perceptual hash.
592/// 3. Put it through the gates a receiver's loader will apply. A refusal costs
593///    another candidate and nothing else.
594/// 4. Derive the key from the draft's hash: Argon2id, then HKDF.
595/// 5. Fill the container-sized buffer, and encrypt it.
596/// 6. Render the **final** container: the same field, grain conditioned on the
597///    ciphertext.
598/// 7. Check that it still hashes to what the draft hashed to. The margin makes
599///    this near-certain, but checking is cheap and its failure would be a
600///    container nobody can read.
601/// 8. Write the PNG.
602///
603/// The compression in step 5 is hoisted out of the loop: it does not depend on
604/// the key, and doing it once means a payload that cannot fit is refused before
605/// a single pixel is rendered rather than a minute later.
606fn generate(
607    kdf: &dyn KeyDeriver,
608    plaintext: Zeroizing<Vec<u8>>,
609    password: Zeroizing<Vec<u8>>,
610    dimensions: ContainerDimensions,
611    output_path: &Path,
612) -> Result<GenerateReport, GenerateError> {
613    let cipher = XChaCha20Poly1305Cipher::new();
614
615    // The message becomes its compressed form once, and the plaintext is
616    // dropped — and therefore wiped — at the earliest point the chain allows.
617    let compressed = compress(plaintext.as_slice())?;
618    drop(plaintext);
619
620    let available = fits(&compressed, dimensions, EmbeddingMode::Symmetric)?;
621
622    let mut rng = seed_from_system()?;
623
624    for _ in 0..MAX_CANDIDATES {
625        let texture = Texture::new(rng.next_u64(), dimensions.width(), dimensions.height());
626
627        // Step 2 and 3. The draft exists only in memory, and only long enough
628        // to be judged: it is the cover, and the cover is the thing this mode
629        // exists to not leave lying around.
630        let draft = render(&texture, dimensions, &mut rng, None)?;
631        let Ok(draft_salt) = compute_stable_phash(&draft) else {
632            continue;
633        };
634        if !passes_container_gates(&draft) {
635            continue;
636        }
637        drop(draft);
638
639        // Steps 4 and 5. The password is borrowed rather than consumed: a
640        // candidate that fails at step 7 needs it again.
641        let master_key = kdf.derive(password.as_slice(), &draft_salt)?;
642        let derived_keys = expand_master_key(&master_key)?;
643        drop(master_key);
644
645        let ciphertext = seal(
646            &compressed,
647            dimensions,
648            EmbeddingMode::Symmetric,
649            &mut rng,
650            &derived_keys,
651            &cipher,
652        )?;
653        drop(derived_keys);
654
655        // Steps 6 and 7.
656        let container = render(&texture, dimensions, &mut rng, Some(&ciphertext))?;
657        drop(ciphertext);
658
659        let Ok(final_salt) = compute_stable_phash(&container) else {
660            continue;
661        };
662        if final_salt.as_bytes() != draft_salt.as_bytes() || shows_jpeg_grid(&container) {
663            continue;
664        }
665
666        write_png(&container, output_path)?;
667
668        return Ok(GenerateReport {
669            image_dimensions: container.dimensions(),
670            payload_bytes: compressed.len(),
671            capacity_bytes: available,
672        });
673    }
674
675    Err(GenerateError::NoUsableTexture {
676        candidates: MAX_CANDIDATES,
677    })
678}
679
680/// Builds a `dimensions` container around `plaintext` for the holder of
681/// `recipient`, and writes it to `output_path`.
682///
683/// **Experimental, and compiled only behind the `pqc` feature.** The layout it
684/// writes is not yet a settled format; see the module documentation.
685///
686/// The counterpart of [`generate_container`] with no password anywhere: the
687/// message key is drawn fresh, encapsulated to the recipient's public key, and
688/// the encapsulation travels inside the container. Nothing has to be agreed
689/// beforehand, and — because the key owes nothing to the container — reusing an
690/// image is harmless here rather than merely discouraged.
691///
692/// # Errors
693///
694/// Returns a [`GenerateError`] when the system random number generator cannot
695/// be read, the compressed payload does not fit the requested size, no
696/// candidate texture passes the container gates, a cryptographic step fails, or
697/// the file cannot be written.
698#[cfg(feature = "pqc")]
699pub fn generate_container_for_recipient(
700    plaintext: Zeroizing<Vec<u8>>,
701    recipient: &RecipientKey,
702    dimensions: ContainerDimensions,
703    output_path: &Path,
704) -> Result<GenerateReport, GenerateError> {
705    let cipher = XChaCha20Poly1305Cipher::new();
706
707    let compressed = compress(plaintext.as_slice())?;
708    drop(plaintext);
709
710    let available = fits(&compressed, dimensions, ASYMMETRIC_MODE)?;
711
712    let mut rng = seed_from_system()?;
713
714    // The one structural difference from the password path, and the reason this
715    // is a separate function rather than a branch inside that loop: the shared
716    // secret does not depend on the container, so the encapsulation and the
717    // sealing happen once, above the candidate search, instead of once per
718    // candidate. There is no draft to render either — the draft exists only to
719    // fix a perceptual hash the key is derived from, and here nothing is.
720    //
721    // What survives is the loop itself and the gates it applies: a candidate
722    // still has to hash reproducibly and still has to be a container a
723    // receiver's loader and `scan` accept. It is judged in its final form,
724    // which is the only form there is.
725    let (kem_ciphertext, derived_keys) = recipient.encapsulate()?;
726    let sealed = seal(
727        &compressed,
728        dimensions,
729        ASYMMETRIC_MODE,
730        &mut rng,
731        &derived_keys,
732        &cipher,
733    )?;
734    drop(derived_keys);
735
736    // The encapsulation goes first in sample order, so a receiver can read it
737    // knowing nothing at all — which is the only order that can work, since
738    // everything else is behind the key it carries.
739    //
740    // Placing it at a known offset costs nothing *here*, and the reason is the
741    // reason this mode exists: no sample is modified, every sample is drawn
742    // from the texture's own distribution conditioned on the bit it carries, so
743    // the conditioned distribution equals the marginal. An analyst who knows
744    // exactly which twelve thousand bits hold the encapsulation has no change
745    // to measure the density of, because there was no change. That is *not*
746    // true of a container a payload was embedded into, where a known region is
747    // a region a targeted test can be aimed at; that case is a different
748    // problem and is not solved by copying this layout.
749    let mut carrier = Zeroizing::new(Vec::with_capacity(dimensions.capacity()));
750    carrier.extend_from_slice(&kem_ciphertext);
751    carrier.extend_from_slice(&sealed);
752    drop(sealed);
753
754    for _ in 0..MAX_CANDIDATES {
755        let texture = Texture::new(rng.next_u64(), dimensions.width(), dimensions.height());
756        let container = render(&texture, dimensions, &mut rng, Some(&carrier))?;
757
758        // The hash is not what any key is derived from in this mode, and it is
759        // still checked: `extract` computes it before it tries anything, and a
760        // container whose hash will not settle is one nobody can hand to it.
761        if compute_stable_phash(&container).is_err() || !passes_container_gates(&container) {
762            continue;
763        }
764
765        write_png(&container, output_path)?;
766
767        return Ok(GenerateReport {
768            image_dimensions: container.dimensions(),
769            payload_bytes: compressed.len(),
770            capacity_bytes: available,
771        });
772    }
773
774    Err(GenerateError::NoUsableTexture {
775        candidates: MAX_CANDIDATES,
776    })
777}
778
779/// Checks a compressed payload against what `dimensions` admits in `mode`.
780///
781/// Returns the admitted figure, so the caller can report it without asking a
782/// second time.
783///
784/// # Errors
785///
786/// Returns [`GenerateError::PayloadTooLarge`], carrying a square container that
787/// would hold the payload in this same mode.
788fn fits(
789    compressed: &[u8],
790    dimensions: ContainerDimensions,
791    mode: EmbeddingMode,
792) -> Result<usize, GenerateError> {
793    let available = dimensions.payload_capacity(mode);
794
795    if compressed.len() > available {
796        return Err(GenerateError::PayloadTooLarge {
797            payload: compressed.len(),
798            available,
799            deficit: compressed.len() - available,
800            // A square suggestion even for a rectangular request: it is the one
801            // shape a single figure describes, and the user is free to spend it
802            // on whichever pair of sides they like.
803            recommended_side: recommended_square_side(compressed.len(), mode),
804        });
805    }
806
807    Ok(available)
808}
809
810/// A generator seeded with [`SEED_BYTES`] bytes from the system CSPRNG.
811///
812/// The seed is wiped as soon as the generator holds it. The generator's own
813/// state cannot be wiped from outside — `StdRng` exposes no way to reach it —
814/// which is why the seed is the thing that is guarded and why it is drawn from
815/// the operating system rather than from anything reproducible.
816///
817/// # Errors
818///
819/// Returns [`GenerateError::Entropy`] when the system generator cannot be read.
820/// There is no fallback on purpose.
821fn seed_from_system() -> Result<StdRng, GenerateError> {
822    let mut seed = Zeroizing::new([0u8; SEED_BYTES]);
823
824    SysRng
825        .try_fill_bytes(seed.as_mut_slice())
826        .map_err(|err| GenerateError::Entropy(err.to_string()))?;
827
828    let rng = StdRng::from_seed(*seed);
829    drop(seed);
830
831    Ok(rng)
832}
833
834/// Whether a candidate would survive the journey to a receiver.
835///
836/// The gates of layer 1 and of the cost layer, applied to a buffer that never
837/// went through a file. That is deliberate: [`crate::image_io::validate::load_and_validate`]
838/// is the only public way to obtain an [`ImageBuffer`], and it needs a path —
839/// but this code lives inside the crate, so it can build the buffer directly
840/// and hand it to the very same analyses. The perceptual hash is checked by the
841/// caller, which needs its value rather than its verdict.
842///
843/// The cost model has no part in the embedding here and is checked anyway: it
844/// is what `scan` runs, so a container that failed it would be one the tool
845/// itself reports as unusable.
846fn passes_container_gates(image: &ImageBuffer) -> bool {
847    !shows_jpeg_grid(image) && HillCostProvider::new().compute(image).is_ok()
848}
849
850/// Whether the block detector of layer 1 would read a JPEG grid in `image`.
851///
852/// Applied to the final container as well as to the draft, unlike the cost
853/// model: the detector samples blocks at random and it is the final container
854/// that will be handed to it, whereas the cost model measures the texture
855/// energy of a field the two share.
856fn shows_jpeg_grid(image: &ImageBuffer) -> bool {
857    let (width, height) = image.dimensions();
858
859    detect_jpeg_artifacts(image.pixels(), width, height, image.color_space()).is_some()
860}
861
862/// Renders one container.
863///
864/// With `carrier` present, the least significant bit of every sample is drawn
865/// to equal the corresponding ciphertext bit, most significant bit of each byte
866/// first. Samples past the end of the ciphertext are drawn freely, which for
867/// the geometry this mode uses is none of them: the ciphertext is sized to fill
868/// the container exactly.
869///
870/// # Errors
871///
872/// Returns [`GenerateError::Sampling`] if conditioned sampling fails to
873/// converge, which no base level this crate's texture produces can cause.
874fn render(
875    texture: &Texture,
876    dimensions: ContainerDimensions,
877    rng: &mut StdRng,
878    carrier: Option<&[u8]>,
879) -> Result<ImageBuffer, GenerateError> {
880    let (width, height) = (dimensions.width(), dimensions.height());
881    let mut samples = vec![0u8; width as usize * height as usize * CHANNELS];
882    let carrier_bits = carrier.map_or(0, |bytes| bytes.len() * 8);
883
884    let mut position = 0usize;
885    for y in 0..height {
886        for x in 0..width {
887            // Once per pixel rather than once per channel: the field is a
888            // property of the position, and the three channels are tints of it.
889            let base_levels = texture.base_levels(x, y);
890
891            for &base in base_levels.iter() {
892                let value = match carrier {
893                    Some(bytes) if position < carrier_bits => {
894                        // In range: `carrier_bits` is `bytes.len() * 8`.
895                        let byte = bytes.get(position / 8).copied().unwrap_or(0);
896                        let bit = (byte >> (7 - position % 8)) & 1;
897                        draw_with_lsb(rng, base, bit)?
898                    }
899                    _ => draw_free(rng, base),
900                };
901
902                if let Some(sample) = samples.get_mut(position) {
903                    *sample = value;
904                }
905                position += 1;
906            }
907        }
908    }
909
910    Ok(ImageBuffer::new(samples, width, height, ColorSpace::Rgb8))
911}
912
913/// Builds the buffer the container is filled with, and encrypts it.
914///
915/// The plaintext of that one encryption is the whole container:
916///
917/// ```text
918/// [u32 big-endian: compressed length][zstd(message)][random padding]
919/// ```
920///
921/// # Why it is filled to the last byte
922///
923/// Two properties, and neither is optional:
924///
925/// 1. **The receiver cannot derive the length from anything else.** Zstandard
926///    returns slightly *more* than it was given on incompressible input, so the
927///    compressed length is not a function of any quantity a receiver holds. It
928///    has to travel, and it travels inside the authenticated plaintext.
929/// 2. **Every container is the same size whatever it carries**, so the size of
930///    the message does not leak. A ciphertext cut to the exact length of the
931///    payload would leak it in full.
932///
933/// The padding is drawn from the CSPRNG rather than left as zeros. It is
934/// encrypted either way, but padding with structure is a temptation with no
935/// upside.
936///
937/// # Errors
938///
939/// Returns [`GenerateError::Crypto`] if the cipher refuses the buffer.
940fn seal(
941    compressed: &[u8],
942    dimensions: ContainerDimensions,
943    mode: EmbeddingMode,
944    rng: &mut StdRng,
945    keys: &DerivedKeys,
946    cipher: &dyn AEADCipher,
947) -> Result<Zeroizing<Vec<u8>>, GenerateError> {
948    let plaintext_len = dimensions
949        .capacity()
950        .saturating_sub(mode.key_transport_overhead_bytes())
951        .saturating_sub(TAG_BYTES);
952
953    let mut buffer = Zeroizing::new(Vec::with_capacity(plaintext_len));
954    // Checked against `payload_capacity` by the caller, so the conversion holds
955    // for any container geometry this crate can build.
956    let announced = u32::try_from(compressed.len()).unwrap_or(u32::MAX);
957    buffer.extend_from_slice(&announced.to_be_bytes());
958    buffer.extend_from_slice(compressed);
959
960    let filled = buffer.len();
961    buffer.resize(plaintext_len, 0);
962    if let Some(padding) = buffer.get_mut(filled..) {
963        rng.fill_bytes(padding);
964    }
965
966    let ciphertext = cipher.encrypt(keys.enc_key(), keys.nonce(), &buffer, STENOXIDE_AAD)?;
967    drop(buffer);
968
969    Ok(ciphertext)
970}
971
972/// Reads the payload out of a container that was generated around it.
973///
974/// The counterpart of [`generate`], and the second of the two readings
975/// [`crate::pipeline::EmbedPipeline::extract`] tries. It needs no cost map, no
976/// permutation and no trellis: the ciphertext is the least significant bit of
977/// every sample, in raster order, and it fills the container exactly.
978///
979/// Returns the recovered message and the ciphertext bytes it was read from.
980///
981/// # Errors
982///
983/// Returns a [`CryptoError`] when the container was not generated around a
984/// payload, when it was generated under a different key, or when the
985/// authenticated buffer does not hold a payload of the length it announces.
986/// The caller must not distinguish these from each other, or from the failure
987/// of the other reading: that is the whole reason both are attempted.
988pub(crate) fn read_generated(
989    image: &ImageBuffer,
990    keys: &DerivedKeys,
991    cipher: &dyn AEADCipher,
992) -> Result<(Zeroizing<Vec<u8>>, usize), CryptoError> {
993    read_generated_after(image, 0, keys, cipher)
994}
995
996/// Reads the payload of a container built for a recipient's public key.
997///
998/// The same reader as [`read_generated`], starting past the encapsulation that
999/// occupies the head of the carrier. The keys are the ones decapsulation
1000/// produced, so by the time this is called the identity has already had its
1001/// say — and it cannot have failed, because ML-KEM decapsulation is total; a
1002/// wrong identity arrives here with a wrong key and leaves as an
1003/// authentication failure, indistinguishable from every other one.
1004///
1005/// # Errors
1006///
1007/// As [`read_generated`].
1008#[cfg(feature = "pqc")]
1009pub(crate) fn read_generated_for_recipient(
1010    image: &ImageBuffer,
1011    keys: &DerivedKeys,
1012    cipher: &dyn AEADCipher,
1013) -> Result<(Zeroizing<Vec<u8>>, usize), CryptoError> {
1014    read_generated_after(
1015        image,
1016        ASYMMETRIC_MODE.key_transport_overhead_bytes(),
1017        keys,
1018        cipher,
1019    )
1020}
1021
1022/// Reads the payload out of a container, skipping `key_transport` leading bytes
1023/// of carrier.
1024///
1025/// # Errors
1026///
1027/// As [`read_generated`].
1028fn read_generated_after(
1029    image: &ImageBuffer,
1030    key_transport: usize,
1031    keys: &DerivedKeys,
1032    cipher: &dyn AEADCipher,
1033) -> Result<(Zeroizing<Vec<u8>>, usize), CryptoError> {
1034    let samples = image.pixels();
1035    let capacity = (samples.len() / 8).saturating_sub(key_transport);
1036
1037    if capacity <= TAG_BYTES + LENGTH_HEADER_BYTES {
1038        return Err(CryptoError::AEADError(AEADError::AuthenticationFailed));
1039    }
1040
1041    let ciphertext = Zeroizing::new(gather_carrier_bits(samples, key_transport, capacity));
1042    let buffer = cipher.decrypt(keys.enc_key(), keys.nonce(), &ciphertext, STENOXIDE_AAD)?;
1043
1044    // Past this line the tag has vouched for every byte, so a malformed header
1045    // is damage rather than a wrong key — the same distinction the embedding
1046    // path draws between authentication and decompression.
1047    let Some(header) = buffer.get(..LENGTH_HEADER_BYTES) else {
1048        return Err(CryptoError::DecompressionError(
1049            "the authenticated buffer is shorter than its own length header".to_owned(),
1050        ));
1051    };
1052    let announced = header
1053        .try_into()
1054        .map(|bytes: [u8; LENGTH_HEADER_BYTES]| u32::from_be_bytes(bytes) as usize)
1055        .unwrap_or(0);
1056
1057    let Some(body) = buffer.get(LENGTH_HEADER_BYTES..LENGTH_HEADER_BYTES + announced) else {
1058        return Err(CryptoError::DecompressionError(
1059            "the authenticated buffer announces more payload than it holds".to_owned(),
1060        ));
1061    };
1062
1063    let plaintext = decompress(body)?;
1064    drop(buffer);
1065
1066    Ok((plaintext, capacity))
1067}
1068
1069/// Collects the least significant bit of `bytes * 8` samples, starting past the
1070/// first `skip * 8`.
1071///
1072/// Most significant bit of each output byte first, which is the order
1073/// [`render`] writes them in.
1074fn gather_carrier_bits(samples: &[u8], skip: usize, bytes: usize) -> Vec<u8> {
1075    let mut out = vec![0u8; bytes];
1076    let first = skip * 8;
1077
1078    for (offset, sample) in samples.iter().skip(first).take(bytes * 8).enumerate() {
1079        if let Some(byte) = out.get_mut(offset / 8) {
1080            *byte |= (sample & 1) << (7 - offset % 8);
1081        }
1082    }
1083
1084    out
1085}
1086
1087/// The encapsulation a container built for a recipient carries at its head.
1088///
1089/// `None` when the container is too small to hold one and a payload besides,
1090/// which no container this crate draws in that mode is. A container built any
1091/// other way returns the bits that happen to sit there, which decapsulate to a
1092/// key like any other and fail authentication one step later — the reader has
1093/// no way to tell the two apart, and must not have one.
1094#[cfg(feature = "pqc")]
1095pub(crate) fn read_key_transport(image: &ImageBuffer) -> Option<Vec<u8>> {
1096    let samples = image.pixels();
1097    let key_transport = ASYMMETRIC_MODE.key_transport_overhead_bytes();
1098
1099    if samples.len() / 8 <= key_transport + TAG_BYTES + LENGTH_HEADER_BYTES {
1100        return None;
1101    }
1102
1103    Some(gather_carrier_bits(samples, 0, key_transport))
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
1109    // well. A test that cannot panic cannot fail, so they are lifted here and
1110    // only here.
1111    #![allow(clippy::expect_used)]
1112    #![allow(clippy::panic)]
1113
1114    use super::*;
1115
1116    use crate::crypto::kdf::MasterKey;
1117
1118    /// Keys that are not derived from any container, for the buffer-level tests
1119    /// below. Nothing here is about the derivation.
1120    fn keys() -> DerivedKeys {
1121        expand_master_key(&MasterKey::new([0x3Cu8; 32])).expect("expansion must succeed")
1122    }
1123
1124    /// The container is filled to the last sample it can carry.
1125    #[test]
1126    fn the_ciphertext_is_sized_to_the_container() {
1127        let default = ContainerDimensions::default();
1128        let samples = default.width() as usize * default.height() as usize * CHANNELS;
1129
1130        assert_eq!(default.capacity(), samples / 8);
1131        assert_eq!(default.capacity(), 1_500_000);
1132        assert_eq!(
1133            default.payload_capacity(EmbeddingMode::Symmetric),
1134            1_500_000 - TAG_BYTES - LENGTH_HEADER_BYTES
1135        );
1136    }
1137
1138    /// Building for a recipient costs the container exactly the encapsulation.
1139    ///
1140    /// The figure a user is quoted and the figure a payload is judged against
1141    /// are the same figure, and it drops by the 1568 bytes the sizer says key
1142    /// transport costs — not by a number this module decided for itself. A user
1143    /// told 1.45 MB and refused at 1.44 MB is a user who cannot act on either
1144    /// number.
1145    #[cfg(feature = "pqc")]
1146    #[test]
1147    fn building_for_a_recipient_costs_the_encapsulation_and_nothing_else() {
1148        let overhead = ASYMMETRIC_MODE.key_transport_overhead_bytes();
1149        assert_eq!(overhead, 1_568);
1150
1151        for dimensions in [
1152            ContainerDimensions::default(),
1153            ContainerDimensions::new(2400, 2000).expect("within range"),
1154        ] {
1155            let symmetric = dimensions.payload_capacity(EmbeddingMode::Symmetric);
1156            let asymmetric = dimensions.payload_capacity(ASYMMETRIC_MODE);
1157
1158            assert_eq!(symmetric - asymmetric, overhead);
1159
1160            // And the sealed buffer gives the difference back: what the payload
1161            // loses, the carrier spends on the encapsulation, to the byte.
1162            let sealed_len = dimensions
1163                .capacity()
1164                .saturating_sub(overhead)
1165                .saturating_sub(TAG_BYTES);
1166            assert_eq!(sealed_len + TAG_BYTES + overhead, dimensions.capacity());
1167        }
1168    }
1169
1170    /// Capacity is a straight function of the pixel count, square or not.
1171    ///
1172    /// The whole reason a larger container fits a larger payload: every sample
1173    /// carries one bit, so the admitted payload grows with `width * height` and
1174    /// a rectangle admits exactly what a square of the same area does.
1175    #[test]
1176    fn capacity_follows_the_pixel_count() {
1177        let square = ContainerDimensions::new(4000, 4000).expect("within range");
1178        let rectangle = ContainerDimensions::new(2000, 8000).expect("within range");
1179
1180        assert_eq!(square.capacity(), 4000 * 4000 * CHANNELS / 8);
1181        assert_eq!(square.capacity(), rectangle.capacity());
1182        assert!(square.capacity() > ContainerDimensions::default().capacity());
1183    }
1184
1185    /// The size gates refuse a side below the floor and a product above the cap.
1186    #[test]
1187    fn dimensions_are_held_to_both_gates() {
1188        assert!(ContainerDimensions::new(MIN_CONTAINER_SIDE, MIN_CONTAINER_SIDE).is_ok());
1189
1190        let too_short = ContainerDimensions::new(MIN_CONTAINER_SIDE - 1, MIN_CONTAINER_SIDE)
1191            .map(|_| ())
1192            .expect_err("a side below the floor must be refused");
1193        assert!(matches!(
1194            too_short,
1195            GenerateError::DimensionsOutOfRange { .. }
1196        ));
1197
1198        // A width that alone is fine but multiplies past the ceiling.
1199        let widest = (MAX_CONTAINER_PIXELS / u64::from(MIN_CONTAINER_SIDE)) as u32;
1200        assert!(ContainerDimensions::new(widest, MIN_CONTAINER_SIDE).is_ok());
1201        let over = ContainerDimensions::new(widest + 100, MIN_CONTAINER_SIDE)
1202            .map(|_| ())
1203            .expect_err("a product above the ceiling must be refused");
1204        assert!(matches!(over, GenerateError::DimensionsOutOfRange { .. }));
1205    }
1206
1207    /// The recommended side clears the payload, rounds to a hundred, and gives
1208    /// up only when no permitted container could hold it.
1209    #[test]
1210    fn the_recommended_side_is_round_and_sufficient() {
1211        // The figure from the user report: about 1.78 MB compressed.
1212        let mode = EmbeddingMode::Symmetric;
1213        let side = recommended_square_side(1_782_778, mode).expect("a container this size exists");
1214        assert_eq!(side % 100, 0, "the suggestion must be a round figure");
1215        assert!(side >= MIN_CONTAINER_SIDE);
1216
1217        let admitted = ContainerDimensions::new(side, side)
1218            .expect("the suggestion must be within range")
1219            .payload_capacity(mode);
1220        assert!(
1221            admitted >= 1_782_778,
1222            "a container of the suggested side must actually hold the payload"
1223        );
1224        // And it is not wildly oversized: the previous hundred would not do.
1225        let admitted_below = ContainerDimensions::new(side - 100, side - 100)
1226            .expect("within range")
1227            .payload_capacity(mode);
1228        assert!(admitted_below < 1_782_778);
1229
1230        // A payload no permitted container can hold has no suggestion to make.
1231        let unattainable = (MAX_CONTAINER_PIXELS as usize) * CHANNELS / 8;
1232        assert!(recommended_square_side(unattainable, mode).is_none());
1233    }
1234
1235    /// The suggestion answers in the mode it was asked about.
1236    ///
1237    /// A payload that fits a container exactly in the password mode does not
1238    /// fit the same container when 1568 bytes of it carry an encapsulation, and
1239    /// a suggestion that ignored the mode would name a size the very next
1240    /// attempt refuses.
1241    #[cfg(feature = "pqc")]
1242    #[test]
1243    fn the_recommended_side_accounts_for_key_transport() {
1244        // The largest payload the default container admits with no key
1245        // transport: in the asymmetric mode it needs a bigger container.
1246        let payload = ContainerDimensions::default().payload_capacity(EmbeddingMode::Symmetric);
1247
1248        let suggested = recommended_square_side(payload, ASYMMETRIC_MODE)
1249            .expect("a container this size exists");
1250
1251        assert!(suggested > DEFAULT_CONTAINER_SIDE);
1252        assert!(
1253            ContainerDimensions::new(suggested, suggested)
1254                .expect("the suggestion must be within range")
1255                .payload_capacity(ASYMMETRIC_MODE)
1256                >= payload
1257        );
1258    }
1259
1260    /// The carrier bits are written and read in the same order.
1261    #[test]
1262    fn the_carrier_round_trips_through_the_samples() {
1263        let payload = [0b1010_1010u8, 0b0000_1111, 0xFF, 0x00];
1264
1265        // One sample per bit, carrying nothing but that bit.
1266        let samples: Vec<u8> = (0..payload.len() * 8)
1267            .map(|position| {
1268                let byte = payload[position / 8];
1269                (byte >> (7 - position % 8)) & 1
1270            })
1271            .collect();
1272
1273        assert_eq!(gather_carrier_bits(&samples, 0, payload.len()), payload);
1274
1275        // The high bits of a sample are not part of the carrier.
1276        let noisy: Vec<u8> = samples.iter().map(|bit| bit | 0xF0).collect();
1277        assert_eq!(gather_carrier_bits(&noisy, 0, payload.len()), payload);
1278
1279        // And a skip lands on a byte boundary: reading past the first byte
1280        // gives the rest, which is how the encapsulation is stepped over.
1281        assert_eq!(
1282            gather_carrier_bits(&samples, 1, payload.len() - 1),
1283            payload[1..]
1284        );
1285        assert_eq!(gather_carrier_bits(&samples, 3, 1), payload[3..]);
1286    }
1287
1288    /// The sealed buffer occupies the whole container, whatever it carries.
1289    ///
1290    /// The property that keeps the message size from leaking: a one-byte
1291    /// payload and a large one produce ciphertexts of exactly the same length.
1292    /// The asymmetric mode fills the container just as exactly, minus the space
1293    /// the encapsulation takes at the head of the carrier.
1294    #[test]
1295    fn every_sealed_buffer_is_the_same_size() {
1296        let mut rng = StdRng::seed_from_u64(5);
1297        let cipher = XChaCha20Poly1305Cipher::new();
1298        let keys = keys();
1299        let dimensions = ContainerDimensions::default();
1300
1301        #[cfg(not(feature = "pqc"))]
1302        let modes = [EmbeddingMode::Symmetric];
1303        #[cfg(feature = "pqc")]
1304        let modes = [EmbeddingMode::Symmetric, ASYMMETRIC_MODE];
1305
1306        for mode in modes {
1307            let transported = mode.key_transport_overhead_bytes();
1308
1309            for length in [0usize, 1, 4_096, 100_000] {
1310                let compressed = vec![0x5Au8; length];
1311                let sealed = seal(&compressed, dimensions, mode, &mut rng, &keys, &cipher)
1312                    .expect("a payload within capacity must seal");
1313
1314                assert_eq!(
1315                    sealed.len() + transported,
1316                    dimensions.capacity(),
1317                    "payload of {length} in {mode:?}"
1318                );
1319            }
1320        }
1321    }
1322
1323    /// A sealed buffer reads back through the container-shaped reader.
1324    ///
1325    /// Driven without rendering an image: the samples are synthesised from the
1326    /// ciphertext, which is exactly what a rendered container's least
1327    /// significant bits are.
1328    #[test]
1329    fn a_sealed_payload_is_recovered_by_the_reader() {
1330        let mut rng = StdRng::seed_from_u64(9);
1331        let cipher = XChaCha20Poly1305Cipher::new();
1332        let keys = keys();
1333
1334        let dimensions = ContainerDimensions::default();
1335        let message = b"a message that is compressed, sealed and read back".repeat(4);
1336        let compressed = compress(&message).expect("compression must succeed");
1337        let sealed = seal(
1338            &compressed,
1339            dimensions,
1340            EmbeddingMode::Symmetric,
1341            &mut rng,
1342            &keys,
1343            &cipher,
1344        )
1345        .expect("sealing must succeed");
1346
1347        let samples: Vec<u8> = (0..sealed.len() * 8)
1348            .map(|position| {
1349                let byte = sealed.get(position / 8).copied().unwrap_or(0);
1350                0x80 | ((byte >> (7 - position % 8)) & 1)
1351            })
1352            .collect();
1353        let image = ImageBuffer::new(
1354            samples,
1355            dimensions.width(),
1356            dimensions.height(),
1357            ColorSpace::Rgb8,
1358        );
1359
1360        match read_generated(&image, &keys, &cipher) {
1361            Ok((plaintext, bytes)) => {
1362                assert_eq!(plaintext.as_slice(), message.as_slice());
1363                assert_eq!(bytes, dimensions.capacity());
1364            }
1365            Err(error) => panic!("a sealed payload must be recovered: {error}"),
1366        }
1367
1368        // Any other key is an authentication failure, and says nothing more.
1369        let other = expand_master_key(&MasterKey::new([0x11u8; 32])).expect("expansion");
1370        let error = read_generated(&image, &other, &cipher)
1371            .map(|_| ())
1372            .expect_err("a wrong key must not authenticate");
1373        assert!(
1374            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1375            "got: {error:?}"
1376        );
1377    }
1378
1379    /// The encapsulation is at the head, and the payload starts right after it.
1380    ///
1381    /// Driven at the buffer level, like its symmetric twin above: the samples
1382    /// are synthesised from `encapsulation || sealed`, which is exactly what a
1383    /// rendered container's least significant bits are in that mode. It pins
1384    /// the layout PROMPT27's counterpart has to agree with, without rendering
1385    /// four megapixels to do it.
1386    #[cfg(feature = "pqc")]
1387    #[test]
1388    fn a_recipient_container_carries_the_encapsulation_before_the_payload() {
1389        let mut rng = StdRng::seed_from_u64(11);
1390        let cipher = XChaCha20Poly1305Cipher::new();
1391        let keys = keys();
1392        let dimensions = ContainerDimensions::default();
1393
1394        let message = b"encapsulated, not agreed beforehand".repeat(3);
1395        let compressed = compress(&message).expect("compression must succeed");
1396        let sealed = seal(
1397            &compressed,
1398            dimensions,
1399            ASYMMETRIC_MODE,
1400            &mut rng,
1401            &keys,
1402            &cipher,
1403        )
1404        .expect("sealing must succeed");
1405
1406        // A stand-in for the ML-KEM ciphertext: this test is about where the
1407        // bytes sit, not about what they decapsulate to.
1408        let transport: Vec<u8> = (0..ASYMMETRIC_MODE.key_transport_overhead_bytes())
1409            .map(|index| (index % 251) as u8)
1410            .collect();
1411
1412        let mut carrier = transport.clone();
1413        carrier.extend_from_slice(&sealed);
1414        assert_eq!(carrier.len(), dimensions.capacity());
1415
1416        let samples: Vec<u8> = (0..carrier.len() * 8)
1417            .map(|position| {
1418                let byte = carrier.get(position / 8).copied().unwrap_or(0);
1419                0x80 | ((byte >> (7 - position % 8)) & 1)
1420            })
1421            .collect();
1422        let image = ImageBuffer::new(
1423            samples,
1424            dimensions.width(),
1425            dimensions.height(),
1426            ColorSpace::Rgb8,
1427        );
1428
1429        assert_eq!(
1430            read_key_transport(&image),
1431            Some(transport),
1432            "the encapsulation must be readable with no key at all"
1433        );
1434
1435        match read_generated_for_recipient(&image, &keys, &cipher) {
1436            Ok((plaintext, bytes)) => {
1437                assert_eq!(plaintext.as_slice(), message.as_slice());
1438                assert_eq!(bytes, sealed.len());
1439            }
1440            Err(error) => panic!("a sealed payload must be recovered: {error}"),
1441        }
1442
1443        // Reading it as a password-mode container starts at the wrong byte and
1444        // fails as an authentication failure, like everything else.
1445        let error = read_generated(&image, &keys, &cipher)
1446            .map(|_| ())
1447            .expect_err("the wrong layout must not authenticate");
1448        assert!(
1449            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1450            "got: {error:?}"
1451        );
1452    }
1453
1454    /// A container with no room for an encapsulation and a payload has none.
1455    #[cfg(feature = "pqc")]
1456    #[test]
1457    fn a_container_too_small_for_key_transport_carries_none() {
1458        let image = ImageBuffer::new(vec![0u8; 64], 4, 4, ColorSpace::Rgb8);
1459
1460        assert_eq!(read_key_transport(&image), None);
1461
1462        let error = read_generated_for_recipient(&image, &keys(), &XChaCha20Poly1305Cipher::new())
1463            .map(|_| ())
1464            .expect_err("a container with no room must be refused");
1465        assert!(
1466            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1467            "got: {error:?}"
1468        );
1469    }
1470
1471    /// A container too small to hold a header is refused as an authentication
1472    /// failure, like everything else this reader can refuse.
1473    #[test]
1474    fn a_container_without_room_for_a_payload_is_refused() {
1475        let image = ImageBuffer::new(vec![0u8; 64], 4, 4, ColorSpace::Rgb8);
1476        let error = read_generated(&image, &keys(), &XChaCha20Poly1305Cipher::new())
1477            .map(|_| ())
1478            .expect_err("a container with no room must be refused");
1479
1480        assert!(
1481            matches!(error, CryptoError::AEADError(AEADError::AuthenticationFailed)),
1482            "got: {error:?}"
1483        );
1484    }
1485
1486    /// Every failure explains itself, and the chain of causes is wired.
1487    #[test]
1488    fn every_failure_explains_itself() {
1489        let messages = [
1490            GenerateError::Entropy("no device".to_owned()).to_string(),
1491            GenerateError::PayloadTooLarge {
1492                payload: 2_000_000,
1493                available: 1_499_980,
1494                deficit: 500_020,
1495                recommended_side: recommended_square_side(2_000_000, EmbeddingMode::Symmetric),
1496            }
1497            .to_string(),
1498            GenerateError::DimensionsOutOfRange {
1499                width: 1_000,
1500                height: 3_000,
1501                min_side: MIN_CONTAINER_SIDE,
1502                max_pixels: MAX_CONTAINER_PIXELS,
1503            }
1504            .to_string(),
1505            GenerateError::NoUsableTexture { candidates: 64 }.to_string(),
1506            GenerateError::Sampling(RejectionExhausted).to_string(),
1507            GenerateError::from(KdfError::EmptyPassword).to_string(),
1508            GenerateError::from(ExpandError::HkdfError("too long".to_owned())).to_string(),
1509            GenerateError::from(AEADError::AuthenticationFailed).to_string(),
1510            GenerateError::from(OutputError::MalformedBuffer).to_string(),
1511        ];
1512
1513        for message in &messages {
1514            assert!(!message.is_empty());
1515        }
1516
1517        assert!(messages[0].contains("no device"));
1518        assert!(messages[1].contains("2000000") && messages[1].contains("500020"));
1519        assert!(messages[2].contains("1000x3000") && messages[2].contains("2000"));
1520        assert!(messages[3].contains("64"));
1521
1522        // Only the variants that wrap another error have a cause to chain to.
1523        assert!(std::error::Error::source(&GenerateError::from(KdfError::EmptyPassword)).is_some());
1524        assert!(
1525            std::error::Error::source(&GenerateError::NoUsableTexture { candidates: 1 }).is_none()
1526        );
1527        assert!(std::error::Error::source(&GenerateError::DimensionsOutOfRange {
1528            width: 1_000,
1529            height: 3_000,
1530            min_side: MIN_CONTAINER_SIDE,
1531            max_pixels: MAX_CONTAINER_PIXELS,
1532        })
1533        .is_none());
1534    }
1535
1536    /// The seed comes from the system generator, and it produces a working one.
1537    #[test]
1538    fn the_generator_is_seeded_from_the_system() {
1539        let mut first = seed_from_system().expect("the system generator must be readable");
1540        let mut second = seed_from_system().expect("the system generator must be readable");
1541
1542        // Two draws that agreed would mean the seed was not what it claims.
1543        assert_ne!(first.next_u64(), second.next_u64());
1544    }
1545}