Skip to main content

stenoxide_core/stego/
sizer.rs

1//! Capacity sizer enforcing the `max_bpp` compile-time hard limit.
2//!
3//! The sizer answers one question before anything is encrypted or embedded: how
4//! many payload bytes does this container actually admit? Asking it early is
5//! what keeps the failure cheap and honest — a payload that does not fit is
6//! rejected while it is still plaintext in the caller's hands, rather than after
7//! a key derivation, a compression pass and a full cost analysis.
8//!
9//! # What the numbers mean
10//!
11//! Capacity is whittled down in three steps, and each one is a different kind of
12//! constraint:
13//!
14//! 1. **The security ceiling.** Only [`MAX_BPP`] bits per usable position may be
15//!    embedded. This is not a property of the image or of the code; it is the
16//!    payload rate below which the modern rich-model detectors stay near chance.
17//! 2. **The coding efficiency.** Syndrome-Trellis Codes do not reach the
18//!    rate-distortion bound exactly, so a fraction of the gross bits is spent on
19//!    the code itself. `STC_EFFICIENCY` is the conservative share that
20//!    survives.
21//! 3. **The cryptographic overhead.** The Poly1305 tag rides inside the embedded
22//!    bits and is not payload, so it comes off the top.
23//!
24//! # Why the error says so little
25//!
26//! [`SizerError`] carries the exact figures for the caller that wants them, but
27//! its message deliberately does not print them. A user-visible error is the one
28//! artifact of this system an adversary is most likely to obtain — pasted into a
29//! bug report, a chat, a screenshot — and a message quoting the exact available
30//! byte count leaks the efficiency factor, the overhead and, through them, the
31//! number of usable positions the container was found to have. The advice
32//! "shorten the message or use a larger image" is everything the user needs and
33//! nothing an attacker can key on.
34
35use std::fmt;
36
37use crate::cost::CostMap;
38use crate::stego::stc::MAX_BPP;
39
40/// Share of the gross capacity that survives Syndrome-Trellis coding.
41///
42/// The trellis spends part of the cover on the code itself, and the exact share
43/// depends on the constraint height and on the shape of the cost distribution.
44/// Eighty-five per cent is deliberately pessimistic: the sizer's promise is that
45/// a payload it accepts will embed, so it must round against itself. Advertising
46/// capacity the coder then refuses would turn a clean rejection into a failure
47/// halfway through the pipeline.
48const STC_EFFICIENCY: f32 = 0.85;
49
50/// Bytes of Poly1305 tag carried inside the embedded bits.
51const MAC_OVERHEAD_BYTES: usize = 16;
52
53/// Bytes of ML-KEM-1024 ciphertext carried alongside an asymmetric payload.
54///
55/// The recipient decapsulates it to recover the message key, so it must travel
56/// inside the container and comes out of the same budget as the payload.
57#[cfg(feature = "pqc")]
58const ML_KEM_1024_CIPHERTEXT_BYTES: usize = 1568;
59
60/// Bits in a byte, named where the conversion happens.
61const BITS_PER_BYTE: usize = 8;
62
63/// The one way capacity planning can fail.
64#[derive(Debug)]
65pub enum SizerError {
66    /// The payload is larger than the container admits.
67    PayloadTooLarge {
68        /// Size of the payload, in bytes.
69        payload: usize,
70        /// Bytes the container admits.
71        available: usize,
72        /// How many bytes over the limit the payload is.
73        deficit: usize,
74    },
75}
76
77impl fmt::Display for SizerError {
78    /// Explains what to do, never what the limit is; see the module
79    /// documentation.
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            SizerError::PayloadTooLarge { .. } => write!(
83                f,
84                "the message does not fit in this image; shorten the message or use an image of \
85                 higher resolution"
86            ),
87        }
88    }
89}
90
91impl std::error::Error for SizerError {}
92
93/// How the message key reaches the recipient.
94///
95/// The choice changes what has to be embedded besides the payload, which is why
96/// capacity planning needs to know about it.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum EmbeddingMode {
99    /// Both sides derive the key from a shared password. Nothing but the
100    /// ciphertext and its tag is embedded.
101    #[default]
102    Symmetric,
103    /// The message key is encapsulated to the recipient's ML-KEM-1024 public
104    /// key, and the resulting ciphertext is embedded with the payload.
105    #[cfg(feature = "pqc")]
106    AsymmetricPqc,
107}
108
109impl EmbeddingMode {
110    /// Bytes this mode spends on getting the key to the recipient.
111    ///
112    /// Zero for [`EmbeddingMode::Symmetric`], where the key never travels: the
113    /// recipient rederives it from the password and the container itself.
114    pub fn key_transport_overhead_bytes(self) -> usize {
115        match self {
116            EmbeddingMode::Symmetric => 0,
117            #[cfg(feature = "pqc")]
118            EmbeddingMode::AsymmetricPqc => ML_KEM_1024_CIPHERTEXT_BYTES,
119        }
120    }
121}
122
123/// What a container can carry, broken down by the constraint that shaped it.
124///
125/// The fields are `pub(crate)` and mirrored by accessors: the report is a
126/// measurement, and nothing outside this layer should be able to write one that
127/// no cost map produced.
128#[derive(Debug, Clone, Copy)]
129pub struct CapacityReport {
130    /// Pixels in the container.
131    pub(crate) total_pixels: usize,
132    /// Pixels the embedder may use, i.e. those with a strictly positive cost.
133    pub(crate) textured_pixels: usize,
134    /// Bits allowed over the usable pixels by the [`MAX_BPP`] ceiling.
135    pub(crate) gross_capacity_bits: usize,
136    /// Bits left once Syndrome-Trellis coding has taken its share.
137    pub(crate) net_capacity_bits: usize,
138    /// Bytes of fixed cryptographic overhead: the Poly1305 tag.
139    pub(crate) mac_overhead_bytes: usize,
140    /// Bytes of payload the container admits.
141    pub(crate) available_bytes: usize,
142}
143
144impl CapacityReport {
145    /// Pixels in the container.
146    pub fn total_pixels(&self) -> usize {
147        self.total_pixels
148    }
149
150    /// Pixels the embedder may use.
151    pub fn textured_pixels(&self) -> usize {
152        self.textured_pixels
153    }
154
155    /// Bits allowed by the [`MAX_BPP`] ceiling, before coding overhead.
156    pub fn gross_capacity_bits(&self) -> usize {
157        self.gross_capacity_bits
158    }
159
160    /// Bits left once Syndrome-Trellis coding has taken its share.
161    pub fn net_capacity_bits(&self) -> usize {
162        self.net_capacity_bits
163    }
164
165    /// Bytes of fixed cryptographic overhead.
166    pub fn mac_overhead_bytes(&self) -> usize {
167        self.mac_overhead_bytes
168    }
169
170    /// Bytes of payload the container admits.
171    pub fn available_bytes(&self) -> usize {
172        self.available_bytes
173    }
174}
175
176/// Measures what `cost_map` can carry in the given mode.
177///
178/// Total: every input produces a report. A container with no usable pixel is not
179/// an error here, it is a report whose `available_bytes` is zero — deciding what
180/// to do about that is [`validate_payload_fits`]'s job, and it needs a payload
181/// size to say anything useful.
182///
183/// A position counts as usable when its cost is strictly positive. Zero is the
184/// reserved value for "no embedder may touch this"; the HILL model never emits
185/// it, since its costs are reciprocals of a non-negative quantity, so on a HILL
186/// map every pixel counts and the ceiling is what binds.
187pub fn compute_capacity(cost_map: &CostMap<'_>, mode: EmbeddingMode) -> CapacityReport {
188    let total_pixels = cost_map.pixel_count();
189    let textured_pixels = cost_map.costs().iter().filter(|&&cost| cost > 0.0).count();
190
191    // In `f32`, matching `StcConfig::capacity_bits` exactly: the coder rejects a
192    // payload the sizer accepted if the two disagree by even one bit, so both
193    // sides compute the ceiling the same way rather than the most precise way.
194    let gross_capacity_bits = (textured_pixels as f32 * MAX_BPP) as usize;
195    let net_capacity_bits = (gross_capacity_bits as f32 * STC_EFFICIENCY) as usize;
196
197    // Saturating throughout: a small container can owe more overhead than it has
198    // capacity, and that is a container with no room for a payload — not a
199    // subtraction that should wrap into an enormous one.
200    let available_bytes = (net_capacity_bits / BITS_PER_BYTE)
201        .saturating_sub(MAC_OVERHEAD_BYTES)
202        .saturating_sub(mode.key_transport_overhead_bytes());
203
204    CapacityReport {
205        total_pixels,
206        textured_pixels,
207        gross_capacity_bits,
208        net_capacity_bits,
209        mac_overhead_bytes: MAC_OVERHEAD_BYTES,
210        available_bytes,
211    }
212}
213
214/// Checks a payload of `payload_len` bytes against a measured container.
215///
216/// # Errors
217///
218/// Returns [`SizerError::PayloadTooLarge`] when the payload exceeds
219/// `report.available_bytes`, carrying the payload size, the available size and
220/// the difference for callers that need to report progress towards a fit.
221pub fn validate_payload_fits(
222    payload_len: usize,
223    report: &CapacityReport,
224) -> Result<(), SizerError> {
225    if payload_len > report.available_bytes {
226        return Err(SizerError::PayloadTooLarge {
227            payload: payload_len,
228            available: report.available_bytes,
229            deficit: payload_len - report.available_bytes,
230        });
231    }
232
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
239    // well. A test that cannot panic cannot fail, so they are lifted here and
240    // only here.
241    #![allow(clippy::expect_used)]
242    #![allow(clippy::panic)]
243
244    use super::*;
245
246    use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
247
248    /// Side length of the synthetic containers below, in pixels.
249    const SIDE: u32 = 200;
250
251    /// A container of [`SIDE`] squared pixels. Only its geometry matters here.
252    fn image() -> ImageBuffer {
253        ImageBuffer::new(
254            vec![0u8; (SIDE * SIDE) as usize],
255            SIDE,
256            SIDE,
257            ColorSpace::Luma8,
258        )
259    }
260
261    /// A map in which `usable` positions carry a positive cost and the rest are
262    /// marked untouchable.
263    fn map(image: &ImageBuffer, usable: usize) -> CostMap<'_> {
264        let costs = (0..image.pixel_count())
265            .map(|index| if index < usable { 1.0 } else { 0.0 })
266            .collect();
267
268        CostMap::new(image, costs)
269    }
270
271    /// Capacity is whittled down by the ceiling, the coder and the tag, in that
272    /// order, and the report says which step took what.
273    #[test]
274    fn capacity_is_reported_step_by_step() {
275        let image = image();
276        let map = map(&image, image.pixel_count());
277        let report = compute_capacity(&map, EmbeddingMode::Symmetric);
278
279        assert_eq!(report.total_pixels(), image.pixel_count());
280        assert_eq!(report.textured_pixels(), image.pixel_count());
281        assert_eq!(
282            report.gross_capacity_bits(),
283            (image.pixel_count() as f32 * MAX_BPP) as usize
284        );
285        assert_eq!(
286            report.net_capacity_bits(),
287            (report.gross_capacity_bits() as f32 * STC_EFFICIENCY) as usize
288        );
289        assert_eq!(report.mac_overhead_bytes(), MAC_OVERHEAD_BYTES);
290        assert_eq!(
291            report.available_bytes(),
292            report.net_capacity_bits() / BITS_PER_BYTE - MAC_OVERHEAD_BYTES
293        );
294    }
295
296    /// Only positions with a strictly positive cost count towards capacity.
297    #[test]
298    fn positions_of_zero_cost_carry_nothing() {
299        let image = image();
300        let half = image.pixel_count() / 2;
301
302        let full = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
303        let halved = compute_capacity(&map(&image, half), EmbeddingMode::Symmetric);
304
305        assert_eq!(halved.textured_pixels(), half);
306        assert_eq!(halved.total_pixels(), full.total_pixels());
307        assert!(halved.available_bytes() < full.available_bytes());
308    }
309
310    /// A container too small to pay for its own tag has no room at all, and
311    /// says so rather than wrapping into an enormous capacity.
312    #[test]
313    fn a_container_that_cannot_pay_the_tag_admits_nothing() {
314        let tiny = ImageBuffer::new(vec![0u8; 64], 8, 8, ColorSpace::Luma8);
315        let report = compute_capacity(&map(&tiny, tiny.pixel_count()), EmbeddingMode::Symmetric);
316
317        assert_eq!(report.available_bytes(), 0);
318    }
319
320    /// Nothing is spent on key transport when both sides derive the key.
321    #[test]
322    fn the_symmetric_mode_transports_no_key() {
323        assert_eq!(EmbeddingMode::default(), EmbeddingMode::Symmetric);
324        assert_eq!(EmbeddingMode::Symmetric.key_transport_overhead_bytes(), 0);
325    }
326
327    /// The exact boundary: the last payload that fits, and the first that does
328    /// not.
329    #[test]
330    fn the_last_byte_that_fits_is_accepted_and_the_next_is_not() {
331        let image = image();
332        let report = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
333        let available = report.available_bytes();
334
335        assert!(available > 0, "the fixture must have room to measure");
336        assert!(validate_payload_fits(available, &report).is_ok());
337        assert!(validate_payload_fits(available - 1, &report).is_ok());
338
339        match validate_payload_fits(available + 1, &report) {
340            Err(SizerError::PayloadTooLarge {
341                payload,
342                available: reported,
343                deficit,
344            }) => {
345                assert_eq!(payload, available + 1);
346                assert_eq!(reported, available);
347                assert_eq!(deficit, 1);
348            }
349            Ok(()) => panic!("one byte over the limit must be refused"),
350        }
351    }
352
353    /// The refusal tells the user what to do and nothing about the container.
354    #[test]
355    fn the_refusal_leaks_no_parameter() {
356        let message = SizerError::PayloadTooLarge {
357            payload: 4_242,
358            available: 1_337,
359            deficit: 2_905,
360        }
361        .to_string();
362
363        assert!(message.contains("shorten the message"));
364        for leak in [
365            "4242", "1337", "2905", "bpp", "0.02", "byte", "capacity", "pixel",
366        ] {
367            assert!(
368                !message.contains(leak),
369                "the message must not expose {leak:?}: {message}"
370            );
371        }
372    }
373}