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}