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}