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}