Skip to main content

stenoxide_core/pipeline/
mod.rs

1//! Layer 5 — pipeline orchestration.
2//!
3//! Chains layers 1 to 4 with explicit ownership transfer at every step, so
4//! each sensitive buffer is dropped and zeroed at the earliest possible point.
5//! The extraction path needs no cost map: STC decoding operates on the
6//! syndrome of all pixels rather than on a stored position list.
7//!
8//! # The ownership chain
9//!
10//! Every step of [`EmbedPipeline::embed`] carries a comment explaining what it
11//! hands over and what the borrow checker guarantees at that point. Two of those
12//! guarantees are the reason this layer is written the way it is:
13//!
14//! - **Nothing sensitive outlives its use.** The password dies with the master
15//!   key derivation, the master key with its expansion, the plaintext with its
16//!   encryption, the ciphertext and the subkeys with the trellis pass. Each is
17//!   moved into a scope that ends at that point rather than kept in a variable
18//!   the rest of the function could still read.
19//! - **The cost map and the samples cannot disagree.** `CostMap<'img>` holds the
20//!   borrow of the image, so `pixels_mut()` does not compile while the map is
21//!   alive. Embedding into pixels whose costs were computed from different
22//!   samples is exactly the mistake that would steer changes into the wrong
23//!   regions, and here it is a compile error rather than a convention.
24//!
25//! # What travels in the container
26//!
27//! Nothing but bits. No metadata, no salt, no nonce, no length prefix outside
28//! the frame described in `frame`: the salt is recomputed from the image, the
29//! nonce and the permutation seed are derived from it, and the only thing the
30//! receiver is told is how many ciphertext bytes to decode.
31
32pub mod error;
33
34// Readable inside the crate rather than private to this module: `generate`
35// writes containers of its own and there is one function in this workspace that
36// turns a sample buffer into a PNG on disk.
37pub(crate) mod frame;
38
39use std::path::Path;
40
41use zeroize::Zeroizing;
42
43use crate::cost::hill::{CostError, HillCostProvider};
44use crate::cost::CostProvider;
45use crate::crypto::aead::{
46    compress_and_encrypt, decrypt_and_decompress, AEADCipher, AEADError, CryptoError,
47    XChaCha20Poly1305Cipher,
48};
49use crate::crypto::expand::{expand_master_key, DerivedKeys};
50use crate::crypto::kdf::{Argon2Kdf, KeyDeriver};
51#[cfg(feature = "pqc")]
52use crate::crypto::kem::Identity;
53use crate::generate::read_generated;
54#[cfg(feature = "pqc")]
55use crate::generate::{read_generated_for_recipient, read_key_transport};
56use crate::image_io::buffer::{CoverSource, ImageBuffer};
57use crate::image_io::phash::{
58    compute_stable_phash, phash_salt_hypotheses, recover_phash_salt, PHashError, PHashSalt,
59};
60use crate::image_io::validate::load_and_validate;
61use crate::stego::permute::generate_pixel_permutation;
62use crate::stego::sizer::{compute_capacity, validate_payload_fits, EmbeddingMode, SizerError};
63use crate::stego::stc::{stc_decode_safe, stc_encode_safe, StcConfig};
64
65pub use crate::pipeline::error::{OutputError, PipelineError};
66
67/// Ciphertext bytes decoded provisionally to tell two salt hypotheses apart.
68///
69/// The figure comes from [`recover_phash_salt`], which decrypts the head of this
70/// prefix with the raw XChaCha20 keystream and looks for a Zstandard frame magic
71/// number. Sixty-four bytes is one XChaCha20 block, and nothing shorter would
72/// let the discriminator seek past the block the AEAD reserves for its one-time
73/// MAC key.
74const PROVISIONAL_PREFIX_BYTES: usize = 64;
75
76/// What one embedding operation did to the container.
77///
78/// A measurement of the finished stego image, not a receipt the receiver needs:
79/// none of these numbers travels with the payload, and the extraction path
80/// recomputes everything it needs from the image itself.
81#[derive(Debug)]
82pub struct EmbedReport {
83    /// Positions whose carrier bit the trellis flipped, across both regions of
84    /// the frame.
85    pub pixels_modified: usize,
86    /// Ciphertext bytes embedded, tag included.
87    ///
88    /// The compressed and encrypted size, not the length of the message the
89    /// caller handed in: the plaintext length is not something the container
90    /// carries, and reporting it here would suggest otherwise.
91    pub payload_bytes: usize,
92    /// Bits embedded per pixel of the container, frame header included.
93    ///
94    /// The figure the security of the scheme rests on. It is bounded by
95    /// [`crate::stego::stc::MAX_BPP`] on each region of the frame, so it can
96    /// never reach that ceiling over the image as a whole.
97    pub effective_bpp: f32,
98    /// Dimensions of the container as `(width, height)`, in pixels.
99    pub image_dimensions: (u32, u32),
100}
101
102/// What one extraction operation recovered.
103#[derive(Debug)]
104pub struct ExtractReport {
105    /// Ciphertext bytes recovered from the container, tag included.
106    ///
107    /// Counted before decryption and decompression, so it measures how much of
108    /// the container was in use rather than how long the message is — the caller
109    /// already holds the plaintext and can measure that itself.
110    pub payload_bytes: usize,
111}
112
113/// The orchestrator of layers 1 to 4.
114///
115/// Generic over the three components that have a choice of implementation, so
116/// that a test can substitute a cheap key deriver or a stub cost model without
117/// any of the code below knowing. The production instantiation is built by
118/// [`EmbedPipeline::default_secure`].
119pub struct EmbedPipeline<KDF, AEAD, COST> {
120    /// Password stretching. Argon2id in production.
121    kdf: KDF,
122    /// Authenticated encryption. XChaCha20-Poly1305 in production.
123    aead: AEAD,
124    /// Per-pixel embedding costs. HILL in production.
125    cost: COST,
126}
127
128impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST> {
129    /// Assembles a pipeline from its three components.
130    ///
131    /// Unconstrained on purpose: the bounds belong on the operations, not on
132    /// construction, so that a caller can hold a pipeline built from anything
133    /// and only meet the requirements when it embeds or extracts.
134    pub fn new(kdf: KDF, aead: AEAD, cost: COST) -> Self {
135        Self { kdf, aead, cost }
136    }
137}
138
139impl EmbedPipeline<Argon2Kdf, XChaCha20Poly1305Cipher, HillCostProvider> {
140    /// Builds the pipeline this project considers secure: Argon2id at 128 MiB
141    /// and four passes, XChaCha20-Poly1305, and the HILL cost model.
142    ///
143    /// There is no constructor that weakens any of the three. A pipeline whose
144    /// components were chosen at run time would look identical at the API
145    /// surface to this one and behave nothing like it.
146    pub fn default_secure() -> Self {
147        Self::new(
148            Argon2Kdf::default_secure(),
149            XChaCha20Poly1305Cipher::new(),
150            HillCostProvider::new(),
151        )
152    }
153}
154
155/// Result of one extraction attempt under a single salt hypothesis.
156///
157/// A rejection is not an error: with an uncertain hash bit there are two
158/// hypotheses, and the first one being wrong is an ordinary step of the
159/// protocol, not a failure to report.
160enum Attempt {
161    /// The payload authenticated and decompressed.
162    Recovered {
163        /// The recovered message.
164        plaintext: Zeroizing<Vec<u8>>,
165        /// Ciphertext bytes that were decoded to produce it.
166        ciphertext_bytes: usize,
167    },
168    /// The payload did not authenticate under this hypothesis.
169    ///
170    /// Carries the provisionally decoded ciphertext prefix, which is what
171    /// [`recover_phash_salt`] needs to decide whether the hypothesis or the
172    /// password was at fault. The prefix is empty when the length header itself
173    /// decoded to nonsense, in which case the discriminator rejects both
174    /// hypotheses and the caller moves on to the alternative.
175    Rejected(Zeroizing<Vec<u8>>),
176}
177
178impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST>
179where
180    KDF: KeyDeriver,
181    AEAD: AEADCipher,
182    COST: CostProvider<Error = CostError>,
183{
184    /// Hides `plaintext` in the container at `image_path` and writes the result
185    /// to `output_path`.
186    ///
187    /// Both secrets are taken by value in a [`Zeroizing`] wrapper: the pipeline
188    /// becomes their owner and wipes them at the point in the chain where they
189    /// stop being needed, which a borrow could not guarantee.
190    ///
191    /// # Errors
192    ///
193    /// Returns a [`PipelineError`] wrapping the error of whichever layer refused
194    /// the operation: an unusable container, an unstable perceptual hash, a
195    /// smooth image, a payload that does not fit, a failure of the coder, or a
196    /// file that could not be written.
197    pub fn embed(
198        &self,
199        image_path: &Path,
200        plaintext: Zeroizing<Vec<u8>>,
201        password: Zeroizing<Vec<u8>>,
202        output_path: &Path,
203    ) -> Result<EmbedReport, PipelineError> {
204        // Step 1 — the container enters the chain. `load_and_validate` is the
205        // only producer of an `ImageBuffer`, so from here on the type itself is
206        // the proof that every validation gate ran. The pipeline owns it, and
207        // will still own it when it is written back out.
208        let mut image_buffer = load_and_validate(image_path)?;
209        let image_dimensions = image_buffer.dimensions();
210        let pixel_count = image_buffer.pixel_count();
211
212        // Step 2 — the salt is a function of the container. Only a shared borrow
213        // is taken, so `image_buffer` is untouched and still owned by us.
214        let phash_salt = compute_stable_phash(&image_buffer)?;
215
216        // Step 3 — the password is consumed here and nowhere else. Both it and
217        // the salt are dropped as soon as the derivation returns: `Zeroizing`
218        // wipes the password bytes and `ZeroizeOnDrop` wipes the salt, so
219        // neither survives the statement that used it.
220        let master_key = self.kdf.derive(password.as_slice(), &phash_salt)?;
221        drop(password);
222        drop(phash_salt);
223
224        // Step 4 — the master key is expanded and immediately destroyed. It is
225        // passed by reference, so `expand_master_key` never owns key material it
226        // did not create and the wipe happens here, at the earliest point the
227        // chain allows.
228        let derived_keys = expand_master_key(&master_key)?;
229        drop(master_key);
230
231        // Step 5 — the message becomes ciphertext. The intermediate compressed
232        // buffer lives and dies inside `compress_and_encrypt`; the plaintext is
233        // dropped the moment it returns, which is the last instant it is needed.
234        let ciphertext = compress_and_encrypt(
235            plaintext.as_slice(),
236            derived_keys.enc_key(),
237            derived_keys.nonce(),
238            &self.aead,
239        )?;
240        drop(plaintext);
241
242        // Step 6 — the cost map borrows the image for as long as it lives.
243        // INVARIANT: from this line until `drop(cost_map)` the compiler refuses
244        // every call to `image_buffer.pixels_mut()`. The samples the map was
245        // computed from and the samples the coder will modify are therefore the
246        // same samples, and that is checked, not assumed.
247        let cost_map = self.cost.compute(&image_buffer)?;
248
249        // Step 7 — capacity is measured and the payload is checked against it
250        // before a single position is touched. The frame overhead is charged to
251        // the payload here because the sizer measures the container as a whole
252        // and knows nothing about the header region.
253        let capacity = compute_capacity(&cost_map, EmbeddingMode::Symmetric);
254        validate_payload_fits(ciphertext.len() + frame::FRAME_OVERHEAD_BYTES, &capacity)?;
255
256        // Step 8 — the secret visiting order. It depends only on the seed and on
257        // the pixel count, both of which the receiver can reproduce.
258        let permutation = generate_pixel_permutation(pixel_count, derived_keys.stc_seed());
259
260        // Step 9 — everything the coder needs is copied out of the image and the
261        // map, in embedding order. Both reads are shared borrows and coexist
262        // happily; what matters is that they are the *last* reads, because the
263        // next statement releases the map's borrow and the one after that takes
264        // a unique borrow of the samples.
265        let mut cover_symbols = frame::gather_cover_symbols(&image_buffer, &permutation);
266        let cost_reordered = frame::reorder_costs(cost_map.costs(), &permutation);
267
268        // The copy above is what makes this drop possible, and the drop is what
269        // makes `image_buffer` mutable again.
270        drop(cost_map);
271
272        // Step 10 — two trellis passes over disjoint regions of the permuted
273        // positions: the length header first, then the ciphertext. See
274        // [`frame`] for why the length cannot simply ride inside the payload.
275        let length_header = frame::encode_length_header(ciphertext.len()).ok_or_else(|| {
276            // Unreachable after the capacity check: a container able to carry a
277            // ciphertext this long does not exist. Reported as an oversized
278            // payload because that is exactly what it is.
279            SizerError::PayloadTooLarge {
280                payload: ciphertext.len(),
281                available: u32::MAX as usize,
282                deficit: ciphertext.len().saturating_sub(u32::MAX as usize),
283            }
284        })?;
285
286        let stc_config = StcConfig::new(*derived_keys.stc_seed());
287
288        let (header_costs, payload_costs) = frame::split_regions(&cost_reordered);
289        let (header_cover, payload_cover) = frame::split_regions_mut(&mut cover_symbols);
290
291        let header_changes =
292            stc_encode_safe(header_cover, header_costs, &length_header, &stc_config)?;
293        let payload_changes = stc_encode_safe(
294            payload_cover,
295            payload_costs,
296            ciphertext.as_slice(),
297            &stc_config,
298        )?;
299
300        let payload_bytes = ciphertext.len();
301
302        // The coder has taken everything it needed from them, so the ciphertext
303        // and every derived key leave memory here: `Zeroizing` wipes the first,
304        // `ZeroizeOnDrop` the other two.
305        drop(ciphertext);
306        drop(derived_keys);
307        drop(stc_config);
308        drop(cost_reordered);
309
310        // Step 11 — the stego symbols go back into the carrier bits. This is the
311        // unique borrow that the cost map was standing in the way of, and it is
312        // the only mutation of the container in the whole crate.
313        frame::apply_cover_symbols(&mut image_buffer, &permutation, &cover_symbols);
314        drop(permutation);
315        drop(cover_symbols);
316
317        frame::write_png(&image_buffer, output_path)?;
318
319        // Step 12 — the report is pure metadata. `image_buffer` is dropped as
320        // this returns and is deliberately not wiped: its contents are the file
321        // just written to disk, so there is nothing in it an attacker could not
322        // read there instead.
323        let embedded_bits = frame::LENGTH_HEADER_BITS + payload_bytes * 8;
324
325        Ok(EmbedReport {
326            pixels_modified: header_changes + payload_changes,
327            payload_bytes,
328            effective_bpp: embedded_bits as f32 / pixel_count.max(1) as f32,
329            image_dimensions,
330        })
331    }
332
333    /// Recovers the message hidden in the stego image at `stego_path`.
334    ///
335    /// # Why extraction needs no cost map
336    ///
337    /// STC decoding operates on the syndrome `H x stego (mod 2)` taken over
338    /// *all* the positions of a region. The receiver does not need to know which
339    /// pixels were modified, and there is no position list to transmit or store:
340    /// it only has to reproduce the permutation, which follows from the same
341    /// `stc_seed` derived from the same `MasterKey` derived from the same
342    /// password and the same image. That is the whole reason the container
343    /// carries no metadata at all — and the reason the expensive half of
344    /// embedding, the HILL analysis, has no counterpart here.
345    ///
346    /// # Why it reads two kinds of container
347    ///
348    /// A container may have been produced by [`crate::generate`] rather than by
349    /// [`EmbedPipeline::embed`], and nothing in the file says which — a marker
350    /// would be the one piece of metadata this design does not carry. Both
351    /// readings are therefore attempted under one key derivation, and every
352    /// failure is the single failure below. The second reading costs a stream
353    /// cipher over the container and no second Argon2id pass, because both
354    /// constructions derive from the same perceptual hash of the same image.
355    ///
356    /// # Errors
357    ///
358    /// Returns a [`PipelineError`] wrapping the error of whichever layer
359    /// refused: an unusable file, a hash too unstable to reproduce, a coder
360    /// failure, or [`AEADError::AuthenticationFailed`] — which collapses a wrong
361    /// password, a wrong image and a damaged payload into one answer on purpose.
362    pub fn extract(
363        &self,
364        stego_path: &Path,
365        password: Zeroizing<Vec<u8>>,
366    ) -> Result<(Zeroizing<Vec<u8>>, ExtractReport), PipelineError> {
367        // Step 1 — the stego image goes through the same gates as a cover. A
368        // container that would have been refused for embedding cannot be one
369        // this crate produced.
370        let stego_image = load_and_validate(stego_path)?;
371
372        // Step 2 — the salt hypotheses. One when every hash bit is stable, two
373        // when embedding may have pushed a coefficient across the median. The
374        // password is not consumed yet: with `k == 1` it may have to stretch
375        // more than once, so it is kept until every hypothesis is spent.
376        let hypotheses = phash_salt_hypotheses(&stego_image)?;
377
378        // Steps 3 to 8 under the hypothesis the image itself suggests.
379        match self.attempt_extract(&stego_image, &hypotheses.primary, password.as_slice())? {
380            Attempt::Recovered {
381                plaintext,
382                ciphertext_bytes,
383            } => {
384                drop(password);
385
386                Ok((
387                    plaintext,
388                    ExtractReport {
389                        payload_bytes: ciphertext_bytes,
390                    },
391                ))
392            }
393            Attempt::Rejected(prefix) => {
394                let Some(alternative) = hypotheses.alternative else {
395                    // Every hash bit was stable, so the salt was certainly the
396                    // right one and the failure is genuine.
397                    drop(password);
398
399                    return Err(PipelineError::Crypto(CryptoError::AEADError(
400                        AEADError::AuthenticationFailed,
401                    )));
402                };
403
404                // `k == 1`. The prefix was decoded under the primary hypothesis,
405                // so `recover_phash_salt` can only confirm that hypothesis — and
406                // that is precisely the question being asked. A confirmation
407                // means the seed was right and the payload really is unusable; a
408                // rejection means the uncertain bit measured the other way on
409                // the cover, and the alternative deserves a full attempt.
410                let verdict = recover_phash_salt(
411                    &stego_image,
412                    password.as_slice(),
413                    &self.kdf,
414                    prefix.as_slice(),
415                );
416                drop(prefix);
417
418                let outcome = match verdict {
419                    Ok(confirmed) => {
420                        drop(confirmed);
421                        drop(password);
422
423                        return Err(PipelineError::Crypto(CryptoError::AEADError(
424                            AEADError::AuthenticationFailed,
425                        )));
426                    }
427                    Err(PHashError::RecoveryFailed) => {
428                        self.attempt_extract(&stego_image, &alternative, password.as_slice())
429                    }
430                    Err(err) => Err(PipelineError::PHash(err)),
431                };
432
433                drop(password);
434
435                match outcome? {
436                    Attempt::Recovered {
437                        plaintext,
438                        ciphertext_bytes,
439                    } => Ok((
440                        plaintext,
441                        ExtractReport {
442                            payload_bytes: ciphertext_bytes,
443                        },
444                    )),
445                    Attempt::Rejected(_) => Err(PipelineError::Crypto(CryptoError::AEADError(
446                        AEADError::AuthenticationFailed,
447                    ))),
448                }
449            }
450        }
451    }
452
453    /// Recovers a message that was encapsulated to `identity`, rather than
454    /// hidden under a password.
455    ///
456    /// **Experimental, and compiled only behind the `pqc` feature.**
457    ///
458    /// The counterpart of [`crate::generate::generate_container_for_recipient`].
459    /// The mode is selected by the caller handing over an identity instead of a
460    /// password — never guessed from the container, which carries nothing that
461    /// says which mode built it and must not.
462    ///
463    /// # Why this path is not folded into [`EmbedPipeline::extract`]
464    ///
465    /// Because trying both would cost the expensive half of both. The password
466    /// path pays for Argon2id at 128 MiB before it can look at anything; this
467    /// one never touches Argon2id at all. Attempting the asymmetric reading
468    /// inside the password path would gain nothing — there is no identity to
469    /// try it with — and attempting the password readings here would mean
470    /// stretching a password nobody supplied.
471    ///
472    /// # The oracle, and the clock
473    ///
474    /// Every failure below is the same failure the password path reports, as
475    /// the same value: a wrong identity, a container carrying nothing and a
476    /// damaged payload are one answer. Decapsulation cannot fail — FIPS 203
477    /// specifies implicit rejection, so a ciphertext that was not encapsulated
478    /// to this key yields a pseudorandom secret rather than an error — which is
479    /// what keeps a wrong identity from short-circuiting: it does the same work
480    /// as the right one and dies at the same Poly1305 tag.
481    ///
482    /// The two modes are not the same *speed*, and that is deliberate rather
483    /// than overlooked. This path costs a decapsulation and one pass over the
484    /// container, some tens of milliseconds; the password path costs Argon2id
485    /// on top of that. But an observer timing this process already knows which
486    /// mode is running, because the mode is an argument on the command line and
487    /// not a property of the container. What a clock must not separate is the
488    /// outcomes *within* one mode, and here it cannot: success and every kind
489    /// of failure run the identical sequence up to a tag comparison.
490    ///
491    /// A caller that unlocked the identity from a file has also, by then, paid
492    /// one Argon2id for the file's passphrase — so the two modes end up within
493    /// the same order of magnitude of each other in practice.
494    ///
495    /// # Errors
496    ///
497    /// Returns a [`PipelineError`] wrapping [`AEADError::AuthenticationFailed`]
498    /// for every recoverable failure, or the error of the layer that refused
499    /// the file outright.
500    #[cfg(feature = "pqc")]
501    pub fn extract_with_identity(
502        &self,
503        stego_path: &Path,
504        identity: &Identity,
505    ) -> Result<(Zeroizing<Vec<u8>>, ExtractReport), PipelineError> {
506        // Step 1 — the same gates a cover goes through, as everywhere else.
507        let stego_image = load_and_validate(stego_path)?;
508
509        let rejected = || {
510            PipelineError::Crypto(CryptoError::AEADError(AEADError::AuthenticationFailed))
511        };
512
513        // Step 2 — the encapsulation, read from the head of the carrier. No key
514        // is needed to find it, which is the whole reason it is there; see the
515        // `generate` module for why that is free in this construction and not
516        // in the embedding one.
517        let Some(kem_ciphertext) = read_key_transport(&stego_image) else {
518            return Err(rejected());
519        };
520
521        // Step 3 — the message keys. Total: any container at all produces some
522        // key here, and only the tag below decides whether it was the right one.
523        let derived_keys = identity
524            .decapsulate(&kem_ciphertext)
525            .map_err(|_| rejected())?;
526        drop(kem_ciphertext);
527
528        // Step 4 — the payload, and the single failure.
529        let outcome = read_generated_for_recipient(&stego_image, &derived_keys, &self.aead);
530        drop(derived_keys);
531
532        let (plaintext, ciphertext_bytes) = outcome.map_err(|_| rejected())?;
533
534        Ok((
535            plaintext,
536            ExtractReport {
537                payload_bytes: ciphertext_bytes,
538            },
539        ))
540    }
541
542    /// Runs the extraction chain once, under one candidate salt.
543    ///
544    /// The inverse of steps 3 to 10 of [`EmbedPipeline::embed`], and the unit the
545    /// hypothesis search repeats. Everything it derives — master key, subkeys,
546    /// permutation — is local and dropped before it returns, so a failed attempt
547    /// leaves nothing behind for the next one to trip over.
548    ///
549    /// # Why two readings, and why one derivation
550    ///
551    /// A container may have been generated *around* its payload rather than
552    /// embedded into — see [`crate::generate`] — and the two are read by
553    /// completely different code. Nothing in the file says which it is, and
554    /// nothing may: a flag would be the marker this project has gone to some
555    /// trouble not to carry, and a distinct error would let an attacker holding
556    /// a candidate password learn which construction produced an image.
557    ///
558    /// So both are tried and both failures are the same failure. It costs
559    /// almost nothing because the expensive step is Argon2id and the two
560    /// readings derive from the same perceptual hash of the same image: one
561    /// derivation serves both, and the second reading is a stream cipher over
562    /// the container and nothing else.
563    ///
564    /// # Errors
565    ///
566    /// Returns a [`PipelineError`] only for failures that no other hypothesis
567    /// could repair. A payload that does not authenticate is reported as
568    /// [`Attempt::Rejected`], because with an uncertain hash bit that is a
569    /// question about the salt and not yet an error.
570    fn attempt_extract(
571        &self,
572        stego_image: &ImageBuffer,
573        salt: &PHashSalt,
574        password: &[u8],
575    ) -> Result<Attempt, PipelineError> {
576        // Steps 3 and 4 — the same derivation the sender ran, in the same order.
577        // Both intermediates are wiped as soon as the next value exists.
578        let master_key = self.kdf.derive(password, salt)?;
579        let derived_keys = expand_master_key(&master_key)?;
580        drop(master_key);
581
582        let outcome = match self.decode_trellis(stego_image, &derived_keys)? {
583            recovered @ Attempt::Recovered { .. } => recovered,
584            // The trellis found nothing. Under these very keys the container
585            // may still be one that was generated around its payload, and that
586            // reading is what the prefix would otherwise be discarded for.
587            Attempt::Rejected(prefix) => {
588                match read_generated(stego_image, &derived_keys, &self.aead) {
589                    Ok((plaintext, ciphertext_bytes)) => Attempt::Recovered {
590                        plaintext,
591                        ciphertext_bytes,
592                    },
593                    // Every way of failing here is the way the trellis reading
594                    // already failed, so the attempt ends exactly as it would
595                    // have without this second try — prefix included, because
596                    // the salt discriminator upstream still wants it.
597                    Err(_) => Attempt::Rejected(prefix),
598                }
599            }
600        };
601
602        drop(derived_keys);
603
604        Ok(outcome)
605    }
606
607    /// The Syndrome-Trellis reading of a container, under keys already derived.
608    ///
609    /// Steps 5 to 10 of the inverse chain. Split from [`Self::attempt_extract`]
610    /// so that the derivation above it happens once and serves both readings.
611    ///
612    /// # Errors
613    ///
614    /// As [`Self::attempt_extract`]: only failures no other hypothesis could
615    /// repair.
616    fn decode_trellis(
617        &self,
618        stego_image: &ImageBuffer,
619        derived_keys: &DerivedKeys,
620    ) -> Result<Attempt, PipelineError> {
621        // Step 5 — the visiting order, reproduced rather than transmitted.
622        let permutation =
623            generate_pixel_permutation(stego_image.pixel_count(), derived_keys.stc_seed());
624        let cover_symbols = frame::gather_cover_symbols(stego_image, &permutation);
625        drop(permutation);
626
627        let stc_config = StcConfig::new(*derived_keys.stc_seed());
628        let (header_region, payload_region) = frame::split_regions(&cover_symbols);
629
630        // Step 6 — the length header. Its region is a constant number of
631        // positions, which is what makes this decode possible at all.
632        let header = stc_decode_safe(header_region, frame::LENGTH_HEADER_BITS, &stc_config)?;
633
634        // Step 7 — a header decoded under the wrong seed is uniformly random, so
635        // the length it announces has to be judged before it is acted on. An
636        // implausible one ends the attempt with an empty prefix, which the
637        // discriminator upstream reads as "this hypothesis explains nothing".
638        let announced = frame::decode_length_header(&header).unwrap_or(0);
639        if announced < frame::MIN_CIPHERTEXT_BYTES
640            || announced.saturating_mul(8) > stc_config.capacity_bits(payload_region.len())
641        {
642            return Ok(Attempt::Rejected(Zeroizing::new(Vec::new())));
643        }
644
645        // Step 8 — the payload region, decoded to the exact length announced.
646        let ciphertext =
647            Zeroizing::new(stc_decode_safe(payload_region, announced * 8, &stc_config)?);
648        drop(cover_symbols);
649        drop(stc_config);
650
651        // Step 9 — authentication, then decompression. Nothing reaches the
652        // Zstandard decoder that the Poly1305 tag has not already vouched for.
653        let outcome = decrypt_and_decompress(
654            ciphertext.as_slice(),
655            derived_keys.enc_key(),
656            derived_keys.nonce(),
657            &self.aead,
658        );
659
660        match outcome {
661            Ok(plaintext) => Ok(Attempt::Recovered {
662                plaintext,
663                ciphertext_bytes: announced,
664            }),
665            // Step 10 — the tag rejected the payload. Under a hypothesis that
666            // may be wrong this says nothing yet, so the head of the ciphertext
667            // is handed back for the discriminator to judge.
668            Err(CryptoError::AEADError(_)) => Ok(Attempt::Rejected(Zeroizing::new(
669                ciphertext
670                    .iter()
671                    .copied()
672                    .take(PROVISIONAL_PREFIX_BYTES)
673                    .collect(),
674            ))),
675            // Decompression failed *after* the tag verified: the key was right
676            // and the data is genuinely broken. No other hypothesis can help.
677            Err(err) => Err(PipelineError::Crypto(err)),
678        }
679    }
680}