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
34mod frame;
35
36use std::path::Path;
37
38use zeroize::Zeroizing;
39
40use crate::cost::hill::{CostError, HillCostProvider};
41use crate::cost::CostProvider;
42use crate::crypto::aead::{
43    compress_and_encrypt, decrypt_and_decompress, AEADCipher, AEADError, CryptoError,
44    XChaCha20Poly1305Cipher,
45};
46use crate::crypto::expand::expand_master_key;
47use crate::crypto::kdf::{Argon2Kdf, KeyDeriver};
48use crate::image_io::buffer::{CoverSource, ImageBuffer};
49use crate::image_io::phash::{
50    compute_stable_phash, phash_salt_hypotheses, recover_phash_salt, PHashError, PHashSalt,
51};
52use crate::image_io::validate::load_and_validate;
53use crate::stego::permute::generate_pixel_permutation;
54use crate::stego::sizer::{compute_capacity, validate_payload_fits, EmbeddingMode, SizerError};
55use crate::stego::stc::{stc_decode_safe, stc_encode_safe, StcConfig};
56
57pub use crate::pipeline::error::{OutputError, PipelineError};
58
59/// Ciphertext bytes decoded provisionally to tell two salt hypotheses apart.
60///
61/// The figure comes from [`recover_phash_salt`], which decrypts the head of this
62/// prefix with the raw XChaCha20 keystream and looks for a Zstandard frame magic
63/// number. Sixty-four bytes is one XChaCha20 block, and nothing shorter would
64/// let the discriminator seek past the block the AEAD reserves for its one-time
65/// MAC key.
66const PROVISIONAL_PREFIX_BYTES: usize = 64;
67
68/// What one embedding operation did to the container.
69///
70/// A measurement of the finished stego image, not a receipt the receiver needs:
71/// none of these numbers travels with the payload, and the extraction path
72/// recomputes everything it needs from the image itself.
73#[derive(Debug)]
74pub struct EmbedReport {
75    /// Positions whose carrier bit the trellis flipped, across both regions of
76    /// the frame.
77    pub pixels_modified: usize,
78    /// Ciphertext bytes embedded, tag included.
79    ///
80    /// The compressed and encrypted size, not the length of the message the
81    /// caller handed in: the plaintext length is not something the container
82    /// carries, and reporting it here would suggest otherwise.
83    pub payload_bytes: usize,
84    /// Bits embedded per pixel of the container, frame header included.
85    ///
86    /// The figure the security of the scheme rests on. It is bounded by
87    /// [`crate::stego::stc::MAX_BPP`] on each region of the frame, so it can
88    /// never reach that ceiling over the image as a whole.
89    pub effective_bpp: f32,
90    /// Dimensions of the container as `(width, height)`, in pixels.
91    pub image_dimensions: (u32, u32),
92}
93
94/// What one extraction operation recovered.
95#[derive(Debug)]
96pub struct ExtractReport {
97    /// Ciphertext bytes recovered from the container, tag included.
98    ///
99    /// Counted before decryption and decompression, so it measures how much of
100    /// the container was in use rather than how long the message is — the caller
101    /// already holds the plaintext and can measure that itself.
102    pub payload_bytes: usize,
103}
104
105/// The orchestrator of layers 1 to 4.
106///
107/// Generic over the three components that have a choice of implementation, so
108/// that a test can substitute a cheap key deriver or a stub cost model without
109/// any of the code below knowing. The production instantiation is built by
110/// [`EmbedPipeline::default_secure`].
111pub struct EmbedPipeline<KDF, AEAD, COST> {
112    /// Password stretching. Argon2id in production.
113    kdf: KDF,
114    /// Authenticated encryption. XChaCha20-Poly1305 in production.
115    aead: AEAD,
116    /// Per-pixel embedding costs. HILL in production.
117    cost: COST,
118}
119
120impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST> {
121    /// Assembles a pipeline from its three components.
122    ///
123    /// Unconstrained on purpose: the bounds belong on the operations, not on
124    /// construction, so that a caller can hold a pipeline built from anything
125    /// and only meet the requirements when it embeds or extracts.
126    pub fn new(kdf: KDF, aead: AEAD, cost: COST) -> Self {
127        Self { kdf, aead, cost }
128    }
129}
130
131impl EmbedPipeline<Argon2Kdf, XChaCha20Poly1305Cipher, HillCostProvider> {
132    /// Builds the pipeline this project considers secure: Argon2id at 128 MiB
133    /// and four passes, XChaCha20-Poly1305, and the HILL cost model.
134    ///
135    /// There is no constructor that weakens any of the three. A pipeline whose
136    /// components were chosen at run time would look identical at the API
137    /// surface to this one and behave nothing like it.
138    pub fn default_secure() -> Self {
139        Self::new(
140            Argon2Kdf::default_secure(),
141            XChaCha20Poly1305Cipher::new(),
142            HillCostProvider::new(),
143        )
144    }
145}
146
147/// Result of one extraction attempt under a single salt hypothesis.
148///
149/// A rejection is not an error: with an uncertain hash bit there are two
150/// hypotheses, and the first one being wrong is an ordinary step of the
151/// protocol, not a failure to report.
152enum Attempt {
153    /// The payload authenticated and decompressed.
154    Recovered {
155        /// The recovered message.
156        plaintext: Zeroizing<Vec<u8>>,
157        /// Ciphertext bytes that were decoded to produce it.
158        ciphertext_bytes: usize,
159    },
160    /// The payload did not authenticate under this hypothesis.
161    ///
162    /// Carries the provisionally decoded ciphertext prefix, which is what
163    /// [`recover_phash_salt`] needs to decide whether the hypothesis or the
164    /// password was at fault. The prefix is empty when the length header itself
165    /// decoded to nonsense, in which case the discriminator rejects both
166    /// hypotheses and the caller moves on to the alternative.
167    Rejected(Zeroizing<Vec<u8>>),
168}
169
170impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST>
171where
172    KDF: KeyDeriver,
173    AEAD: AEADCipher,
174    COST: CostProvider<Error = CostError>,
175{
176    /// Hides `plaintext` in the container at `image_path` and writes the result
177    /// to `output_path`.
178    ///
179    /// Both secrets are taken by value in a [`Zeroizing`] wrapper: the pipeline
180    /// becomes their owner and wipes them at the point in the chain where they
181    /// stop being needed, which a borrow could not guarantee.
182    ///
183    /// # Errors
184    ///
185    /// Returns a [`PipelineError`] wrapping the error of whichever layer refused
186    /// the operation: an unusable container, an unstable perceptual hash, a
187    /// smooth image, a payload that does not fit, a failure of the coder, or a
188    /// file that could not be written.
189    pub fn embed(
190        &self,
191        image_path: &Path,
192        plaintext: Zeroizing<Vec<u8>>,
193        password: Zeroizing<Vec<u8>>,
194        output_path: &Path,
195    ) -> Result<EmbedReport, PipelineError> {
196        // Step 1 — the container enters the chain. `load_and_validate` is the
197        // only producer of an `ImageBuffer`, so from here on the type itself is
198        // the proof that every validation gate ran. The pipeline owns it, and
199        // will still own it when it is written back out.
200        let mut image_buffer = load_and_validate(image_path)?;
201        let image_dimensions = image_buffer.dimensions();
202        let pixel_count = image_buffer.pixel_count();
203
204        // Step 2 — the salt is a function of the container. Only a shared borrow
205        // is taken, so `image_buffer` is untouched and still owned by us.
206        let phash_salt = compute_stable_phash(&image_buffer)?;
207
208        // Step 3 — the password is consumed here and nowhere else. Both it and
209        // the salt are dropped as soon as the derivation returns: `Zeroizing`
210        // wipes the password bytes and `ZeroizeOnDrop` wipes the salt, so
211        // neither survives the statement that used it.
212        let master_key = self.kdf.derive(password.as_slice(), &phash_salt)?;
213        drop(password);
214        drop(phash_salt);
215
216        // Step 4 — the master key is expanded and immediately destroyed. It is
217        // passed by reference, so `expand_master_key` never owns key material it
218        // did not create and the wipe happens here, at the earliest point the
219        // chain allows.
220        let derived_keys = expand_master_key(&master_key)?;
221        drop(master_key);
222
223        // Step 5 — the message becomes ciphertext. The intermediate compressed
224        // buffer lives and dies inside `compress_and_encrypt`; the plaintext is
225        // dropped the moment it returns, which is the last instant it is needed.
226        let ciphertext = compress_and_encrypt(
227            plaintext.as_slice(),
228            derived_keys.enc_key(),
229            derived_keys.nonce(),
230            &self.aead,
231        )?;
232        drop(plaintext);
233
234        // Step 6 — the cost map borrows the image for as long as it lives.
235        // INVARIANT: from this line until `drop(cost_map)` the compiler refuses
236        // every call to `image_buffer.pixels_mut()`. The samples the map was
237        // computed from and the samples the coder will modify are therefore the
238        // same samples, and that is checked, not assumed.
239        let cost_map = self.cost.compute(&image_buffer)?;
240
241        // Step 7 — capacity is measured and the payload is checked against it
242        // before a single position is touched. The frame overhead is charged to
243        // the payload here because the sizer measures the container as a whole
244        // and knows nothing about the header region.
245        let capacity = compute_capacity(&cost_map, EmbeddingMode::Symmetric);
246        validate_payload_fits(ciphertext.len() + frame::FRAME_OVERHEAD_BYTES, &capacity)?;
247
248        // Step 8 — the secret visiting order. It depends only on the seed and on
249        // the pixel count, both of which the receiver can reproduce.
250        let permutation = generate_pixel_permutation(pixel_count, derived_keys.stc_seed());
251
252        // Step 9 — everything the coder needs is copied out of the image and the
253        // map, in embedding order. Both reads are shared borrows and coexist
254        // happily; what matters is that they are the *last* reads, because the
255        // next statement releases the map's borrow and the one after that takes
256        // a unique borrow of the samples.
257        let mut cover_symbols = frame::gather_cover_symbols(&image_buffer, &permutation);
258        let cost_reordered = frame::reorder_costs(cost_map.costs(), &permutation);
259
260        // The copy above is what makes this drop possible, and the drop is what
261        // makes `image_buffer` mutable again.
262        drop(cost_map);
263
264        // Step 10 — two trellis passes over disjoint regions of the permuted
265        // positions: the length header first, then the ciphertext. See
266        // [`frame`] for why the length cannot simply ride inside the payload.
267        let length_header = frame::encode_length_header(ciphertext.len()).ok_or_else(|| {
268            // Unreachable after the capacity check: a container able to carry a
269            // ciphertext this long does not exist. Reported as an oversized
270            // payload because that is exactly what it is.
271            SizerError::PayloadTooLarge {
272                payload: ciphertext.len(),
273                available: u32::MAX as usize,
274                deficit: ciphertext.len().saturating_sub(u32::MAX as usize),
275            }
276        })?;
277
278        let stc_config = StcConfig::new(*derived_keys.stc_seed());
279
280        let (header_costs, payload_costs) = frame::split_regions(&cost_reordered);
281        let (header_cover, payload_cover) = frame::split_regions_mut(&mut cover_symbols);
282
283        let header_changes =
284            stc_encode_safe(header_cover, header_costs, &length_header, &stc_config)?;
285        let payload_changes = stc_encode_safe(
286            payload_cover,
287            payload_costs,
288            ciphertext.as_slice(),
289            &stc_config,
290        )?;
291
292        let payload_bytes = ciphertext.len();
293
294        // The coder has taken everything it needed from them, so the ciphertext
295        // and every derived key leave memory here: `Zeroizing` wipes the first,
296        // `ZeroizeOnDrop` the other two.
297        drop(ciphertext);
298        drop(derived_keys);
299        drop(stc_config);
300        drop(cost_reordered);
301
302        // Step 11 — the stego symbols go back into the carrier bits. This is the
303        // unique borrow that the cost map was standing in the way of, and it is
304        // the only mutation of the container in the whole crate.
305        frame::apply_cover_symbols(&mut image_buffer, &permutation, &cover_symbols);
306        drop(permutation);
307        drop(cover_symbols);
308
309        frame::write_png(&image_buffer, output_path)?;
310
311        // Step 12 — the report is pure metadata. `image_buffer` is dropped as
312        // this returns and is deliberately not wiped: its contents are the file
313        // just written to disk, so there is nothing in it an attacker could not
314        // read there instead.
315        let embedded_bits = frame::LENGTH_HEADER_BITS + payload_bytes * 8;
316
317        Ok(EmbedReport {
318            pixels_modified: header_changes + payload_changes,
319            payload_bytes,
320            effective_bpp: embedded_bits as f32 / pixel_count.max(1) as f32,
321            image_dimensions,
322        })
323    }
324
325    /// Recovers the message hidden in the stego image at `stego_path`.
326    ///
327    /// # Why extraction needs no cost map
328    ///
329    /// STC decoding operates on the syndrome `H x stego (mod 2)` taken over
330    /// *all* the positions of a region. The receiver does not need to know which
331    /// pixels were modified, and there is no position list to transmit or store:
332    /// it only has to reproduce the permutation, which follows from the same
333    /// `stc_seed` derived from the same `MasterKey` derived from the same
334    /// password and the same image. That is the whole reason the container
335    /// carries no metadata at all — and the reason the expensive half of
336    /// embedding, the HILL analysis, has no counterpart here.
337    ///
338    /// # Errors
339    ///
340    /// Returns a [`PipelineError`] wrapping the error of whichever layer
341    /// refused: an unusable file, a hash too unstable to reproduce, a coder
342    /// failure, or [`AEADError::AuthenticationFailed`] — which collapses a wrong
343    /// password, a wrong image and a damaged payload into one answer on purpose.
344    pub fn extract(
345        &self,
346        stego_path: &Path,
347        password: Zeroizing<Vec<u8>>,
348    ) -> Result<(Zeroizing<Vec<u8>>, ExtractReport), PipelineError> {
349        // Step 1 — the stego image goes through the same gates as a cover. A
350        // container that would have been refused for embedding cannot be one
351        // this crate produced.
352        let stego_image = load_and_validate(stego_path)?;
353
354        // Step 2 — the salt hypotheses. One when every hash bit is stable, two
355        // when embedding may have pushed a coefficient across the median. The
356        // password is not consumed yet: with `k == 1` it may have to stretch
357        // more than once, so it is kept until every hypothesis is spent.
358        let hypotheses = phash_salt_hypotheses(&stego_image)?;
359
360        // Steps 3 to 8 under the hypothesis the image itself suggests.
361        match self.attempt_extract(&stego_image, &hypotheses.primary, password.as_slice())? {
362            Attempt::Recovered {
363                plaintext,
364                ciphertext_bytes,
365            } => {
366                drop(password);
367
368                Ok((
369                    plaintext,
370                    ExtractReport {
371                        payload_bytes: ciphertext_bytes,
372                    },
373                ))
374            }
375            Attempt::Rejected(prefix) => {
376                let Some(alternative) = hypotheses.alternative else {
377                    // Every hash bit was stable, so the salt was certainly the
378                    // right one and the failure is genuine.
379                    drop(password);
380
381                    return Err(PipelineError::Crypto(CryptoError::AEADError(
382                        AEADError::AuthenticationFailed,
383                    )));
384                };
385
386                // `k == 1`. The prefix was decoded under the primary hypothesis,
387                // so `recover_phash_salt` can only confirm that hypothesis — and
388                // that is precisely the question being asked. A confirmation
389                // means the seed was right and the payload really is unusable; a
390                // rejection means the uncertain bit measured the other way on
391                // the cover, and the alternative deserves a full attempt.
392                let verdict = recover_phash_salt(
393                    &stego_image,
394                    password.as_slice(),
395                    &self.kdf,
396                    prefix.as_slice(),
397                );
398                drop(prefix);
399
400                let outcome = match verdict {
401                    Ok(confirmed) => {
402                        drop(confirmed);
403                        drop(password);
404
405                        return Err(PipelineError::Crypto(CryptoError::AEADError(
406                            AEADError::AuthenticationFailed,
407                        )));
408                    }
409                    Err(PHashError::RecoveryFailed) => {
410                        self.attempt_extract(&stego_image, &alternative, password.as_slice())
411                    }
412                    Err(err) => Err(PipelineError::PHash(err)),
413                };
414
415                drop(password);
416
417                match outcome? {
418                    Attempt::Recovered {
419                        plaintext,
420                        ciphertext_bytes,
421                    } => Ok((
422                        plaintext,
423                        ExtractReport {
424                            payload_bytes: ciphertext_bytes,
425                        },
426                    )),
427                    Attempt::Rejected(_) => Err(PipelineError::Crypto(CryptoError::AEADError(
428                        AEADError::AuthenticationFailed,
429                    ))),
430                }
431            }
432        }
433    }
434
435    /// Runs the extraction chain once, under one candidate salt.
436    ///
437    /// The inverse of steps 3 to 10 of [`EmbedPipeline::embed`], and the unit the
438    /// hypothesis search repeats. Everything it derives — master key, subkeys,
439    /// permutation — is local and dropped before it returns, so a failed attempt
440    /// leaves nothing behind for the next one to trip over.
441    ///
442    /// # Errors
443    ///
444    /// Returns a [`PipelineError`] only for failures that no other hypothesis
445    /// could repair. A payload that does not authenticate is reported as
446    /// [`Attempt::Rejected`], because with an uncertain hash bit that is a
447    /// question about the salt and not yet an error.
448    fn attempt_extract(
449        &self,
450        stego_image: &ImageBuffer,
451        salt: &PHashSalt,
452        password: &[u8],
453    ) -> Result<Attempt, PipelineError> {
454        // Steps 3 and 4 — the same derivation the sender ran, in the same order.
455        // Both intermediates are wiped as soon as the next value exists.
456        let master_key = self.kdf.derive(password, salt)?;
457        let derived_keys = expand_master_key(&master_key)?;
458        drop(master_key);
459
460        // Step 5 — the visiting order, reproduced rather than transmitted.
461        let permutation =
462            generate_pixel_permutation(stego_image.pixel_count(), derived_keys.stc_seed());
463        let cover_symbols = frame::gather_cover_symbols(stego_image, &permutation);
464        drop(permutation);
465
466        let stc_config = StcConfig::new(*derived_keys.stc_seed());
467        let (header_region, payload_region) = frame::split_regions(&cover_symbols);
468
469        // Step 6 — the length header. Its region is a constant number of
470        // positions, which is what makes this decode possible at all.
471        let header = stc_decode_safe(header_region, frame::LENGTH_HEADER_BITS, &stc_config)?;
472
473        // Step 7 — a header decoded under the wrong seed is uniformly random, so
474        // the length it announces has to be judged before it is acted on. An
475        // implausible one ends the attempt with an empty prefix, which the
476        // discriminator upstream reads as "this hypothesis explains nothing".
477        let announced = frame::decode_length_header(&header).unwrap_or(0);
478        if announced < frame::MIN_CIPHERTEXT_BYTES
479            || announced.saturating_mul(8) > stc_config.capacity_bits(payload_region.len())
480        {
481            return Ok(Attempt::Rejected(Zeroizing::new(Vec::new())));
482        }
483
484        // Step 8 — the payload region, decoded to the exact length announced.
485        let ciphertext =
486            Zeroizing::new(stc_decode_safe(payload_region, announced * 8, &stc_config)?);
487        drop(cover_symbols);
488        drop(stc_config);
489
490        // Step 9 — authentication, then decompression. Nothing reaches the
491        // Zstandard decoder that the Poly1305 tag has not already vouched for.
492        let outcome = decrypt_and_decompress(
493            ciphertext.as_slice(),
494            derived_keys.enc_key(),
495            derived_keys.nonce(),
496            &self.aead,
497        );
498        drop(derived_keys);
499
500        match outcome {
501            Ok(plaintext) => Ok(Attempt::Recovered {
502                plaintext,
503                ciphertext_bytes: announced,
504            }),
505            // Step 10 — the tag rejected the payload. Under a hypothesis that
506            // may be wrong this says nothing yet, so the head of the ciphertext
507            // is handed back for the discriminator to judge.
508            Err(CryptoError::AEADError(_)) => Ok(Attempt::Rejected(Zeroizing::new(
509                ciphertext
510                    .iter()
511                    .copied()
512                    .take(PROVISIONAL_PREFIX_BYTES)
513                    .collect(),
514            ))),
515            // Decompression failed *after* the tag verified: the key was right
516            // and the data is genuinely broken. No other hypothesis can help.
517            Err(err) => Err(PipelineError::Crypto(err)),
518        }
519    }
520}