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}