stenoxide_core/stego/stc/native.rs
1//! Syndrome-Trellis Codes, implemented in safe Rust.
2//!
3//! This module replaces the former FFI wrapper around libsdc++. It solves the
4//! same problem the foreign library did, with the same public surface, and
5//! without a single `unsafe` block or a C++ toolchain.
6//!
7//! # The problem being solved
8//!
9//! Given a cover vector `x`, a per-position cost vector `rho` and a message `m`,
10//! find the stego vector `y` that minimises `sum(rho_i * |y_i - x_i|)` subject to
11//! `H * y = m (mod 2)`, where `H` is the parity-check matrix of a convolutional
12//! code. Minimising a sum of costs under a linear constraint is what makes the
13//! embedder *adaptive*: the changes land where the image can hide them, not
14//! wherever the payload happened to fall.
15//!
16//! The reference is Filler, Judas and Fridrich, "Minimizing Additive Distortion
17//! in Steganography using Syndrome-Trellis Codes", IEEE TIFS 2011.
18//!
19//! # How the matrix is represented
20//!
21//! `H` is never materialised. It is defined implicitly by a running `h`-bit
22//! register — the trellis state — and one `h`-bit column per cover position:
23//!
24//! ```text
25//! register = 0
26//! for each block of positions:
27//! for each position j in the block:
28//! if y_j == 1 { register ^= column_j }
29//! message_bit = register & 1
30//! register >>= 1
31//! ```
32//!
33//! That loop *is* the syndrome computation, and it is literally what
34//! [`stc_decode_safe`] runs. Encoding is the same loop read backwards: find the
35//! cheapest assignment of `y` that makes the register produce the message.
36//!
37//! The columns are drawn from a ChaCha20 keystream keyed by `stc_seed`, so
38//! sender and receiver build the same matrix from the same secret without any of
39//! it travelling in the container. Two bits of every column are forced on:
40//!
41//! * **Bit 0.** A block whose columns were all even could not change the parity
42//! the register is about to emit, and the trellis would have no path at all for
43//! one of the two message bits. Forcing the low bit makes every block solvable
44//! by construction rather than with overwhelming probability.
45//! * **Bit `h - 1`.** Without it a change would not reach the top of the register
46//! and the effective constraint height would be shorter than the one asked for.
47//!
48//! Both are properties of the hand-optimised submatrices published with the
49//! reference implementation, arrived at there for the same reasons.
50//!
51//! # Two bounds that are not in the paper
52//!
53//! The published algorithm is `O(n * 2^h)` in time and stores one survivor bit
54//! per position and state, i.e. `n * 2^h` bits. On the four-megapixel containers
55//! this crate insists on, at `h = 10`, that is half a gigabyte of survivor
56//! decisions. Two limits keep the coder inside a working set that a desktop
57//! actually has:
58//!
59//! * `MAX_BLOCK_WIDTH` caps how many cover positions one message bit may be
60//! spread over. A payload at the [`MAX_BPP`] ceiling needs about fifty-nine
61//! positions per bit, so the cap never binds there and the whole container is
62//! used exactly as the design intends; it binds only for payloads well under
63//! the ceiling, where the surplus positions buy a coding gain far below one
64//! change per container. The positions that go unused are simply left alone,
65//! and both sides compute the same count from the cover length and the message
66//! length, so nothing has to be transmitted.
67//! * `MAX_SEGMENT_COLUMNS` caps how much of the trellis one forward pass keeps
68//! survivors for. Longer runs are cut into segments, each solved as its own
69//! trellis with the register reset to zero at the boundary. The decoder resets
70//! at the same places, so the two agree; the only price is the `h` bits of
71//! memory the register would otherwise have carried across, once every
72//! sixty-five thousand positions.
73//!
74//! # What a cover position holds
75//!
76//! One image sample per position — the carrier byte of a pixel, not a bit. The
77//! coder reads its least significant bit as the cover symbol and, when the
78//! trellis asks for a change, writes back `value ± 1` rather than overwriting the
79//! bit. The two are indistinguishable in the payload they carry and very
80//! different in what they leave behind: LSB overwriting pairs each even value
81//! with the odd one above it and never the other way round, which is the
82//! asymmetry RS Analysis and Sample Pair Analysis are built to measure. Adding or
83//! subtracting one, with the direction drawn from the keystream, leaves the
84//! histogram symmetric and those detectors with nothing to read.
85
86use std::fmt;
87
88use chacha20::cipher::{KeyIvInit, StreamCipher};
89use chacha20::ChaCha20;
90use zeroize::{ZeroizeOnDrop, Zeroizing};
91
92/// Bits per pixel the embedder may never exceed.
93///
94/// A hard limit of the design, not a tunable: detection accuracy against the
95/// modern rich-model detectors climbs steeply with payload rate, and everything
96/// this crate does — the HILL costs, the trellis, the permutation — buys
97/// invisibility only in the low-rate regime. Exposing this as a runtime
98/// parameter would let a caller trade away, in one argument, the property the
99/// whole system exists to provide.
100pub const MAX_BPP: f32 = 0.02;
101
102/// Constraint height of the trellis used unless a caller overrides it.
103///
104/// Embedding efficiency grows with the height and so does the cost of the
105/// Viterbi pass, which is `O(2^h)` per position; ten is the value the published
106/// measurements use to sit near the rate-distortion bound without becoming
107/// impractical.
108pub const DEFAULT_TRELLIS_HEIGHT: u32 = 10;
109
110/// Shortest constraint height the coder will run.
111///
112/// At `h = 1` the register has no memory beyond the block it is in and the code
113/// degenerates into plain parity embedding, which is not what any caller asking
114/// for a trellis wants.
115const MIN_TRELLIS_HEIGHT: u32 = 2;
116
117/// Tallest constraint height the coder will run.
118///
119/// The state space doubles with every unit, so twenty already means a million
120/// states per position. The limit is what keeps a mis-set height a clean error
121/// instead of an allocation nobody can serve.
122const MAX_TRELLIS_HEIGHT: u32 = 20;
123
124/// Nonce of the keystream that generates the parity-check columns.
125///
126/// Distinct from the zero nonce [`crate::stego::permute`] uses. Both keystreams
127/// run under the same seed, and drawing the matrix from the same stream as the
128/// permutation would tie the two together for anyone who recovered either.
129const H_MATRIX_NONCE: [u8; 12] = [1u8; 12];
130
131/// Nonce of the keystream that chooses the direction of each change.
132///
133/// A third stream, for the same reason the second one exists.
134const SIGN_NONCE: [u8; 12] = [2u8; 12];
135
136/// Cover positions the trellis will spend on one message bit, at most.
137///
138/// See the module documentation: the cap is above the width a full-capacity
139/// payload asks for, so it costs nothing at the rate the system is designed
140/// around and bounds the work for everything below it.
141const MAX_BLOCK_WIDTH: usize = 64;
142
143/// Cover positions one forward pass keeps survivor decisions for, at most.
144///
145/// At `h = 10` this is eight mebibytes of survivors per segment, which is the
146/// figure the segmentation exists to hold down.
147const MAX_SEGMENT_COLUMNS: usize = 1 << 16;
148
149/// Bytes of ChaCha20 keystream produced per refill.
150const KEYSTREAM_BUFFER_BYTES: usize = 512;
151
152/// Bits in a byte, named where the conversion happens.
153const BITS_PER_BYTE: usize = 8;
154
155/// Every way the Syndrome-Trellis Codes layer can refuse to run.
156#[derive(Debug)]
157pub enum StcError {
158 /// The cover vector and the cost vector describe different position counts.
159 LengthMismatch {
160 /// Number of cover positions supplied.
161 pixels: usize,
162 /// Number of costs supplied.
163 costs: usize,
164 },
165 /// The payload asks for more bits than `max_bpp` allows over this cover.
166 PayloadExceedsCapacity {
167 /// Bits the payload needs.
168 payload_bits: usize,
169 /// Bits the cover may carry under the `max_bpp` hard limit.
170 capacity_bits: usize,
171 },
172 /// A cost was negative, infinite or not a number.
173 InvalidCostMap,
174 /// The trellis could not be built or could not be solved.
175 EncodingError(String),
176 /// The syndrome could not be computed for the parameters given.
177 DecodingError(String),
178}
179
180impl fmt::Display for StcError {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 match self {
183 StcError::LengthMismatch { pixels, costs } => write!(
184 f,
185 "cover and cost vectors disagree: {pixels} positions against {costs} costs"
186 ),
187 StcError::PayloadExceedsCapacity {
188 payload_bits,
189 capacity_bits,
190 } => write!(
191 f,
192 "payload of {payload_bits} bits exceeds the {capacity_bits} bits this cover may \
193 carry"
194 ),
195 StcError::InvalidCostMap => write!(
196 f,
197 "the cost map contains a negative or non-finite value and cannot be used"
198 ),
199 StcError::EncodingError(message) => write!(f, "the stc coder failed: {message}"),
200 StcError::DecodingError(message) => write!(f, "the stc decoder failed: {message}"),
201 }
202 }
203}
204
205impl std::error::Error for StcError {}
206
207/// Parameters of one Syndrome-Trellis Codes operation.
208///
209/// Holds key material — the permutation seed — and is therefore wiped on drop.
210/// Encode and decode must be given identical configurations: the trellis height
211/// is part of the code definition, so a decode at a different height reads a
212/// different syndrome and recovers nothing.
213#[derive(ZeroizeOnDrop)]
214pub struct StcConfig {
215 /// Seed of the embedding permutation, derived by HKDF from the master key.
216 pub(crate) stc_seed: [u8; 32],
217 /// Constraint height of the trellis.
218 pub(crate) trellis_height: u32,
219 /// Bits per pixel ceiling. Always [`MAX_BPP`]; see the constant.
220 pub(crate) max_bpp: f32,
221}
222
223impl StcConfig {
224 /// Builds a configuration around `stc_seed`, with the defaults of this
225 /// crate.
226 ///
227 /// There is no constructor that takes a `max_bpp`. The field exists so the
228 /// limit can be read, not chosen.
229 pub fn new(stc_seed: [u8; 32]) -> Self {
230 Self {
231 stc_seed,
232 trellis_height: DEFAULT_TRELLIS_HEIGHT,
233 max_bpp: MAX_BPP,
234 }
235 }
236
237 /// Borrows the permutation seed.
238 pub fn stc_seed(&self) -> &[u8; 32] {
239 &self.stc_seed
240 }
241
242 /// Constraint height of the trellis.
243 pub fn trellis_height(&self) -> u32 {
244 self.trellis_height
245 }
246
247 /// The bits-per-pixel ceiling in force. Always [`MAX_BPP`].
248 pub fn max_bpp(&self) -> f32 {
249 self.max_bpp
250 }
251
252 /// Bits `positions` cover elements may carry under the ceiling.
253 ///
254 /// Truncating towards zero, so the limit is never rounded up into a payload
255 /// that the ceiling does not actually allow. The arithmetic is done in
256 /// `f32` deliberately: [`crate::stego::sizer`] advertises capacity with the
257 /// same expression, and a wider intermediate type here would let a payload
258 /// pass the sizer and then fail this check.
259 pub fn capacity_bits(&self, positions: usize) -> usize {
260 (positions as f32 * self.max_bpp) as usize
261 }
262}
263
264impl fmt::Debug for StcConfig {
265 /// Prints the parameters of the configuration, never the seed.
266 ///
267 /// Written by hand rather than derived: a derived implementation would put
268 /// key material into every log line and error report that formats a config.
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 f.debug_struct("StcConfig")
271 .field("stc_seed", &"[redacted]")
272 .field("trellis_height", &self.trellis_height)
273 .field("max_bpp", &self.max_bpp)
274 .finish()
275 }
276}
277
278/// Embeds `payload` into the carrier bits of `pixels`, in place.
279///
280/// `pixels` holds one image sample per embedding position — the carrier byte of
281/// each selected pixel — and `cost` holds the price of changing each of them, in
282/// the same order. On success the samples the trellis chose have been moved by
283/// exactly one level, up or down, so that their least significant bits spell the
284/// stego vector; the return value is how many of them moved.
285///
286/// # Errors
287///
288/// Returns [`StcError::LengthMismatch`] when the two vectors disagree in length,
289/// [`StcError::PayloadExceedsCapacity`] when the payload needs more bits than
290/// [`MAX_BPP`] allows, [`StcError::InvalidCostMap`] when a cost is negative or
291/// not finite, and [`StcError::EncodingError`] when the configured trellis height
292/// is out of range or the cover is too short to carry the message. Every check
293/// runs before a single sample is touched, so a rejected input leaves `pixels`
294/// exactly as it was.
295pub fn stc_encode_safe(
296 pixels: &mut [u8],
297 cost: &[f32],
298 payload: &[u8],
299 config: &StcConfig,
300) -> Result<usize, StcError> {
301 // Pre-condition (a) — the two vectors must describe the same positions.
302 if pixels.len() != cost.len() {
303 return Err(StcError::LengthMismatch {
304 pixels: pixels.len(),
305 costs: cost.len(),
306 });
307 }
308
309 // Pre-condition (b) — the payload must fit under the bits-per-pixel ceiling.
310 let payload_bits = payload.len().saturating_mul(BITS_PER_BYTE);
311 let capacity_bits = config.capacity_bits(pixels.len());
312 if payload_bits > capacity_bits {
313 return Err(StcError::PayloadExceedsCapacity {
314 payload_bits,
315 capacity_bits,
316 });
317 }
318
319 // Pre-condition (c) — a NaN would poison the path metric and make every
320 // comparison in the Viterbi pass false, so the trellis would pick a path
321 // arbitrarily rather than cheaply.
322 if !cost.iter().all(|&value| value.is_finite() && value >= 0.0) {
323 return Err(StcError::InvalidCostMap);
324 }
325
326 // Nothing to embed. Returned before the layout is planned so that the
327 // division by the message length below never sees a zero.
328 if payload_bits == 0 {
329 return Ok(0);
330 }
331
332 let Some(height) = validated_height(config.trellis_height) else {
333 return Err(StcError::EncodingError(format!(
334 "a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
335 config.trellis_height
336 )));
337 };
338
339 let Some(layout) = TrellisLayout::plan(pixels.len(), payload_bits) else {
340 return Err(StcError::EncodingError(format!(
341 "a cover of {} positions cannot carry {payload_bits} message bits",
342 pixels.len()
343 )));
344 };
345
346 // Wiped on drop: the unpacked message is the payload, one bit per byte.
347 let message = unpack_bits(payload);
348 let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
349
350 let stego_bits = solve_trellis(pixels, cost, &message, &columns, &layout, height)?;
351
352 Ok(apply_changes(pixels, &stego_bits, &config.stc_seed))
353}
354
355/// Recovers `payload_len_bits` payload bits from the stego samples in `pixels`.
356///
357/// The extraction path needs no cost map: the syndrome is a function of the
358/// stego vector alone, which is exactly what makes it recoverable by a receiver
359/// who never saw the cover. Only the least significant bit of each sample is
360/// read, so a container whose other bits were touched decodes just the same.
361///
362/// The returned vector is the repacked payload, `ceil(payload_len_bits / 8)`
363/// bytes long, with any padding bits of the final byte left zero. It is
364/// plaintext-adjacent material and **must** be wrapped in
365/// [`zeroize::Zeroizing`] by the caller — this function cannot do it on the
366/// caller's behalf without dictating the return type of the whole pipeline.
367///
368/// # Errors
369///
370/// Returns [`StcError::PayloadExceedsCapacity`] when more bits are requested
371/// than [`MAX_BPP`] allows over this cover, and [`StcError::DecodingError`] when
372/// the configured trellis height is out of range or the cover is too short for
373/// the bit count requested.
374pub fn stc_decode_safe(
375 pixels: &[u8],
376 payload_len_bits: usize,
377 config: &StcConfig,
378) -> Result<Vec<u8>, StcError> {
379 let capacity_bits = config.capacity_bits(pixels.len());
380 if payload_len_bits > capacity_bits {
381 return Err(StcError::PayloadExceedsCapacity {
382 payload_bits: payload_len_bits,
383 capacity_bits,
384 });
385 }
386
387 // No bits requested, nothing to read.
388 if payload_len_bits == 0 {
389 return Ok(Vec::new());
390 }
391
392 let Some(height) = validated_height(config.trellis_height) else {
393 return Err(StcError::DecodingError(format!(
394 "a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
395 config.trellis_height
396 )));
397 };
398
399 let Some(layout) = TrellisLayout::plan(pixels.len(), payload_len_bits) else {
400 return Err(StcError::DecodingError(format!(
401 "a cover of {} positions cannot hold {payload_len_bits} message bits",
402 pixels.len()
403 )));
404 };
405
406 let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
407
408 // Wiped on drop: these are the payload bits themselves.
409 let mut message = Zeroizing::new(vec![0u8; payload_len_bits]);
410
411 let mut register = 0u32;
412 let mut position = 0usize;
413
414 for block in 0..layout.message_bits {
415 // The same reset the encoder performs; see [`TrellisLayout`].
416 if block % layout.blocks_per_segment == 0 {
417 register = 0;
418 }
419
420 let block_end = layout.block_start(block + 1);
421 while position < block_end {
422 if pixels.get(position).is_some_and(|sample| sample & 1 == 1) {
423 register ^= columns.get(position).copied().unwrap_or(0);
424 }
425 position += 1;
426 }
427
428 if let Some(slot) = message.get_mut(block) {
429 *slot = (register & 1) as u8;
430 }
431 register >>= 1;
432 }
433
434 Ok(pack_bits(&message))
435}
436
437/// How one trellis run cuts the cover into blocks and segments.
438///
439/// Every field is a function of the cover length and the message length alone,
440/// which is what lets the decoder rebuild the same layout without being told
441/// anything.
442#[derive(Debug, Clone, Copy)]
443struct TrellisLayout {
444 /// Bits of message, i.e. blocks in the trellis.
445 message_bits: usize,
446 /// Cover positions the trellis will actually visit, counted from the start.
447 ///
448 /// Positions beyond this are left untouched by the encoder and ignored by
449 /// the decoder. See [`MAX_BLOCK_WIDTH`] for why there can be any.
450 used_positions: usize,
451 /// Blocks solved as one trellis before the register is reset to zero.
452 blocks_per_segment: usize,
453}
454
455impl TrellisLayout {
456 /// Plans a run of `message_bits` bits over `positions` cover elements.
457 ///
458 /// Returns `None` when there is no message to carry or the cover is shorter
459 /// than the message, which are the two cases where no block layout exists.
460 fn plan(positions: usize, message_bits: usize) -> Option<Self> {
461 if message_bits == 0 {
462 return None;
463 }
464
465 let used_positions = positions.min(message_bits.saturating_mul(MAX_BLOCK_WIDTH));
466 if used_positions < message_bits {
467 return None;
468 }
469
470 // At least one, by the check above. Every block is therefore at least one
471 // position wide, which is what keeps the syndrome of each block settable.
472 let average_width = (used_positions / message_bits).max(1);
473 let blocks_per_segment = (MAX_SEGMENT_COLUMNS / average_width).max(1);
474
475 Some(Self {
476 message_bits,
477 used_positions,
478 blocks_per_segment,
479 })
480 }
481
482 /// First cover position of block `index`, for `index` in `0..=message_bits`.
483 ///
484 /// Spreading the remainder of `used_positions / message_bits` across the
485 /// blocks rather than dropping it means every position between zero and
486 /// `used_positions` belongs to exactly one block, and the widths differ by at
487 /// most one. Computed in `u128` so that the product cannot wrap on a cover
488 /// large enough to matter.
489 fn block_start(&self, index: usize) -> usize {
490 let numerator = index as u128 * self.used_positions as u128;
491
492 (numerator / self.message_bits as u128) as usize
493 }
494}
495
496/// Narrows a configured height to one the coder will run.
497fn validated_height(height: u32) -> Option<u32> {
498 (MIN_TRELLIS_HEIGHT..=MAX_TRELLIS_HEIGHT)
499 .contains(&height)
500 .then_some(height)
501}
502
503/// Draws `count` parity-check columns from the keystream of `seed`.
504///
505/// Each column is `height` bits wide with bit 0 and bit `height - 1` forced on;
506/// see the module documentation for what each of the two guarantees. A raw draw
507/// of zero is discarded and redrawn, so the rejection the specification asks for
508/// happens on the value the keystream actually produced rather than on the
509/// value after the mask has already made it non-zero.
510fn parity_columns(seed: &[u8; 32], height: u32, count: usize) -> Vec<u32> {
511 let mask = (1u32 << height) - 1;
512 let forced = 1u32 | (1u32 << (height - 1));
513
514 let mut keystream = Keystream::new(seed, &H_MATRIX_NONCE);
515 let mut columns = Vec::with_capacity(count);
516
517 for _ in 0..count {
518 let draw = loop {
519 let value = keystream.next_u32() & mask;
520 if value != 0 {
521 break value;
522 }
523 };
524
525 columns.push(draw | forced);
526 }
527
528 columns
529}
530
531/// Finds the cheapest stego vector whose syndrome is `message`.
532///
533/// Returns one stego bit per used cover position, in cover order. The vector is
534/// wiped on drop: read against the cover it names exactly which positions the
535/// trellis wants changed, which is the position list an attacker would otherwise
536/// have to solve for.
537///
538/// # Errors
539///
540/// Returns [`StcError::EncodingError`] if a segment of the trellis admits no
541/// path at all. Every column carries bit 0, so every block can flip the parity it
542/// is about to emit and this cannot happen; the arm is what keeps a future change
543/// to the column generator from silently producing garbage.
544fn solve_trellis(
545 pixels: &[u8],
546 cost: &[f32],
547 message: &[u8],
548 columns: &[u32],
549 layout: &TrellisLayout,
550 height: u32,
551) -> Result<Zeroizing<Vec<u8>>, StcError> {
552 let states = 1usize << height;
553 let half_states = states / 2;
554
555 let mut stego_bits = Zeroizing::new(vec![0u8; layout.used_positions]);
556
557 // The path metric, double-buffered: the transition at one position reads
558 // every state of the previous column while writing every state of this one,
559 // so the two cannot be the same allocation.
560 let mut current = vec![f32::INFINITY; states];
561 let mut next = vec![f32::INFINITY; states];
562
563 let mut first_block = 0usize;
564 while first_block < layout.message_bits {
565 let last_block = (first_block + layout.blocks_per_segment).min(layout.message_bits);
566 let segment_start = layout.block_start(first_block);
567 let segment_end = layout.block_start(last_block);
568
569 // One survivor bit per (position, state) of this segment: whether the
570 // cheapest way to reach that state at that position arrived by setting
571 // the stego bit. This is the only thing the backward pass needs, and the
572 // reason the segmentation exists at all.
573 let mut survivors = Zeroizing::new(vec![
574 0u8;
575 (segment_end - segment_start)
576 .saturating_mul(states)
577 .div_ceil(BITS_PER_BYTE)
578 ]);
579
580 // The register starts every segment at zero, which is where the decoder
581 // will start reading it.
582 current.iter_mut().for_each(|weight| *weight = f32::INFINITY);
583 if let Some(origin) = current.get_mut(0) {
584 *origin = 0.0;
585 }
586
587 // Forward pass, left to right.
588 let mut position = segment_start;
589 for block in first_block..last_block {
590 let block_end = layout.block_start(block + 1);
591
592 while position < block_end {
593 let column = columns.get(position).copied().unwrap_or(1) as usize;
594 let cover_bit = pixels.get(position).copied().unwrap_or(0) & 1;
595 let rho = cost.get(position).copied().unwrap_or(0.0);
596
597 // What each choice of stego bit costs at this position: nothing
598 // if it agrees with the cover, the price of the change if not.
599 let keep = if cover_bit == 0 { 0.0 } else { rho };
600 let flip = if cover_bit == 0 { rho } else { 0.0 };
601
602 let base = (position - segment_start) * states;
603
604 for (state, slot) in next.iter_mut().enumerate() {
605 // Reaching `state` with a stego bit of zero leaves the
606 // register alone; with a one it came from `state ^ column`.
607 let stay = current.get(state).copied().unwrap_or(f32::INFINITY) + keep;
608 let cross = current
609 .get(state ^ column)
610 .copied()
611 .unwrap_or(f32::INFINITY)
612 + flip;
613
614 if cross < stay {
615 set_survivor(&mut survivors, base + state);
616 *slot = cross;
617 } else {
618 *slot = stay;
619 }
620 }
621
622 std::mem::swap(&mut current, &mut next);
623 position += 1;
624 }
625
626 // The block is closed: only the states whose low bit is the message
627 // bit survive, and the register shifts that bit out. Ascending order
628 // is safe because `folded` is never greater than `2 * folded + bit`.
629 let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
630 for folded in 0..half_states {
631 let survivor = current
632 .get(2 * folded + bit)
633 .copied()
634 .unwrap_or(f32::INFINITY);
635
636 if let Some(slot) = current.get_mut(folded) {
637 *slot = survivor;
638 }
639 }
640 for slot in current.iter_mut().skip(half_states) {
641 *slot = f32::INFINITY;
642 }
643 }
644
645 // The trellis is not terminated: the register is free to end anywhere,
646 // because the decoder discards whatever is left in it. The cheapest end
647 // state is therefore simply the cheapest path.
648 let (mut state, best) = current.iter().enumerate().fold(
649 (0usize, f32::INFINITY),
650 |(best_state, best_cost), (state, &weight)| {
651 if weight < best_cost {
652 (state, weight)
653 } else {
654 (best_state, best_cost)
655 }
656 },
657 );
658
659 if !best.is_finite() {
660 return Err(StcError::EncodingError(
661 "the trellis admits no path that satisfies the requested syndrome".to_owned(),
662 ));
663 }
664
665 // Backward pass, right to left, replaying the survivors.
666 let mut position = segment_end;
667 for block in (first_block..last_block).rev() {
668 // Undo the shift the block's closure performed.
669 let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
670 state = state * 2 + bit;
671
672 let block_start = layout.block_start(block);
673 while position > block_start {
674 position -= 1;
675
676 let column = columns.get(position).copied().unwrap_or(1) as usize;
677 let base = (position - segment_start) * states;
678
679 if survivor(&survivors, base + state) {
680 if let Some(slot) = stego_bits.get_mut(position) {
681 *slot = 1;
682 }
683 state ^= column;
684 }
685 }
686 }
687
688 first_block = last_block;
689 }
690
691 Ok(stego_bits)
692}
693
694/// Moves every sample whose carrier bit disagrees with `stego_bits` by one
695/// level, and reports how many moved.
696///
697/// The direction is the `±1` operator of the module documentation: forced
698/// upwards at zero and downwards at the maximum, and otherwise drawn from the
699/// keystream. Either direction flips the least significant bit, so the choice is
700/// free to be made on grounds of detectability alone.
701fn apply_changes(pixels: &mut [u8], stego_bits: &[u8], seed: &[u8; 32]) -> usize {
702 let mut signs = Keystream::new(seed, &SIGN_NONCE);
703 let mut changed = 0usize;
704
705 for (position, &target) in stego_bits.iter().enumerate() {
706 let Some(sample) = pixels.get_mut(position) else {
707 break;
708 };
709
710 if *sample & 1 == target & 1 {
711 continue;
712 }
713
714 // Saturating only in name: the two boundary values are handled by their
715 // own arms, so neither operation below can reach the end of the range.
716 *sample = match *sample {
717 0 => 1,
718 u8::MAX => u8::MAX - 1,
719 value if signs.next_bit() == 1 => value.saturating_add(1),
720 value => value.saturating_sub(1),
721 };
722
723 changed += 1;
724 }
725
726 changed
727}
728
729/// Records that the cheapest path into a state set the stego bit.
730fn set_survivor(survivors: &mut [u8], index: usize) {
731 if let Some(byte) = survivors.get_mut(index / BITS_PER_BYTE) {
732 *byte |= 1 << (index % BITS_PER_BYTE);
733 }
734}
735
736/// Reads back what [`set_survivor`] recorded.
737fn survivor(survivors: &[u8], index: usize) -> bool {
738 survivors
739 .get(index / BITS_PER_BYTE)
740 .is_some_and(|byte| byte & (1 << (index % BITS_PER_BYTE)) != 0)
741}
742
743/// Expands packed bytes into one binary symbol per byte, most significant bit
744/// first.
745///
746/// Wiped on drop: the expansion is a copy of the payload.
747fn unpack_bits(packed: &[u8]) -> Zeroizing<Vec<u8>> {
748 let mut bits = Zeroizing::new(Vec::with_capacity(packed.len().saturating_mul(BITS_PER_BYTE)));
749
750 for byte in packed {
751 for shift in (0..BITS_PER_BYTE).rev() {
752 bits.push((byte >> shift) & 1);
753 }
754 }
755
756 bits
757}
758
759/// Packs binary symbols back into bytes, most significant bit first.
760///
761/// The inverse of [`unpack_bits`] for whole bytes. A count that is not a
762/// multiple of eight leaves the unused low bits of the final byte at zero,
763/// which is why the bit count travels with the payload rather than being
764/// inferred from its length.
765fn pack_bits(bits: &[u8]) -> Vec<u8> {
766 let mut packed = vec![0u8; bits.len().div_ceil(BITS_PER_BYTE)];
767
768 for (index, bit) in bits.iter().enumerate() {
769 if bit & 1 == 1 {
770 if let Some(byte) = packed.get_mut(index / BITS_PER_BYTE) {
771 *byte |= 1 << (BITS_PER_BYTE - 1 - index % BITS_PER_BYTE);
772 }
773 }
774 }
775
776 packed
777}
778
779/// Buffered reader over the ChaCha20 keystream of one coder run.
780///
781/// Wiped on drop: the keystream is a direct function of the STC seed, so a copy
782/// of it left in freed memory is as good as a copy of the seed for anyone who
783/// wants to rebuild the parity-check matrix.
784#[derive(ZeroizeOnDrop)]
785struct Keystream {
786 /// The cipher itself. Skipped by the derive because it does not implement
787 /// `Zeroize`; the `zeroize` feature of the `chacha20` crate — enabled in the
788 /// workspace manifest — already makes it wipe its own state on drop.
789 #[zeroize(skip)]
790 cipher: ChaCha20,
791 /// Keystream bytes produced by the last refill.
792 buffer: [u8; KEYSTREAM_BUFFER_BYTES],
793 /// Offset of the next unread byte in [`Keystream::buffer`].
794 cursor: usize,
795 /// Byte currently being handed out one bit at a time.
796 reservoir: u8,
797 /// Bits already taken out of [`Keystream::reservoir`].
798 taken: u8,
799}
800
801impl Keystream {
802 /// Starts a keystream under `seed` and `nonce`.
803 ///
804 /// Both cursors start spent, so the first read of either kind refills rather
805 /// than returning the zeros the reader was constructed with.
806 fn new(seed: &[u8; 32], nonce: &[u8; 12]) -> Self {
807 Self {
808 cipher: ChaCha20::new(seed.into(), nonce.into()),
809 buffer: [0u8; KEYSTREAM_BUFFER_BYTES],
810 cursor: KEYSTREAM_BUFFER_BYTES,
811 reservoir: 0,
812 taken: u8::try_from(BITS_PER_BYTE).unwrap_or(8),
813 }
814 }
815
816 /// The next keystream byte.
817 fn next_byte(&mut self) -> u8 {
818 if self.cursor >= self.buffer.len() {
819 self.refill();
820 }
821
822 let byte = self.buffer.get(self.cursor).copied().unwrap_or(0);
823 self.cursor += 1;
824
825 byte
826 }
827
828 /// The next four keystream bytes, interpreted as a little-endian `u32`.
829 fn next_u32(&mut self) -> u32 {
830 u32::from_le_bytes([
831 self.next_byte(),
832 self.next_byte(),
833 self.next_byte(),
834 self.next_byte(),
835 ])
836 }
837
838 /// The next keystream bit, least significant bit of each byte first.
839 ///
840 /// Kept on a separate reservoir from [`Keystream::next_byte`] so that a
841 /// reader used for both would still consume whole bytes in order; no reader
842 /// in this module mixes the two.
843 fn next_bit(&mut self) -> u8 {
844 if usize::from(self.taken) >= BITS_PER_BYTE {
845 self.reservoir = self.next_byte();
846 self.taken = 0;
847 }
848
849 let bit = (self.reservoir >> self.taken) & 1;
850 self.taken += 1;
851
852 bit
853 }
854
855 /// Advances the cipher by one buffer's worth of keystream.
856 ///
857 /// The buffer is cleared first because `apply_keystream` XORs into its
858 /// argument: XOR against zero is the keystream itself, XOR against the
859 /// previous contents would be noise.
860 fn refill(&mut self) {
861 self.buffer = [0u8; KEYSTREAM_BUFFER_BYTES];
862 self.cipher.apply_keystream(&mut self.buffer);
863 self.cursor = 0;
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 // The crate-wide `deny(clippy::panic)` reaches into `cfg(test)` code as
870 // well. A test that cannot panic cannot fail, so the ban is lifted here and
871 // only here — every `panic!` below reports a `Result` this module produced
872 // and the specification requires to be `Ok`, which is the one thing an
873 // `assert!` cannot express without an `unwrap`.
874 #![allow(clippy::panic)]
875
876 use super::*;
877
878 use rand::rngs::StdRng;
879 use rand::{RngExt, SeedableRng};
880
881 /// The seed every test derives its configuration from.
882 const SEED: [u8; 32] = [0x5Au8; 32];
883
884 /// Builds a deterministic cover of `len` samples with the full byte range
885 /// represented, so the boundary arms of the `±1` operator are exercised.
886 fn cover(len: usize, seed: u64) -> Vec<u8> {
887 let mut rng = StdRng::seed_from_u64(seed);
888
889 (0..len).map(|_| rng.random()).collect()
890 }
891
892 /// TEST 1 — a payload survives the trellis and comes back out.
893 #[test]
894 fn round_trip_recovers_the_payload() {
895 let mut pixels = cover(10_000, 1);
896 let costs = vec![1.0f32; pixels.len()];
897 let payload = b"test";
898 let config = StcConfig::new(SEED);
899
900 let changed = match stc_encode_safe(&mut pixels, &costs, payload, &config) {
901 Ok(changed) => changed,
902 Err(error) => panic!("embedding into a uniform cover must succeed: {error}"),
903 };
904
905 assert!(
906 changed > 0,
907 "a four-byte payload cannot be carried by a cover nothing was changed in"
908 );
909
910 let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
911 Ok(recovered) => recovered,
912 Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
913 };
914
915 assert_eq!(recovered.as_slice(), payload.as_slice());
916 }
917
918 /// TEST 1b — the same, over a payload long enough to span several blocks per
919 /// segment and to land at the capacity ceiling rather than well under it.
920 ///
921 /// The short round trip above never makes [`MAX_BLOCK_WIDTH`] bind from the
922 /// other side; this one runs at the width a full container actually uses.
923 #[test]
924 fn round_trip_recovers_a_payload_at_the_capacity_ceiling() {
925 let mut pixels = cover(40_000, 2);
926 let costs = vec![1.0f32; pixels.len()];
927 let payload: Vec<u8> = (0..90u16).map(|value| (value % 251) as u8).collect();
928 let config = StcConfig::new(SEED);
929
930 if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
931 panic!("a payload at the ceiling must still embed: {error}");
932 }
933
934 let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
935 Ok(recovered) => recovered,
936 Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
937 };
938
939 assert_eq!(recovered, payload);
940 }
941
942 /// TEST 2 — the same seed, cover and payload always give the same stego
943 /// image.
944 ///
945 /// Not a convenience. The receiver rebuilds the parity-check matrix from the
946 /// seed alone, so a coder that drew anything from a non-reproducible source
947 /// would embed a payload nobody could read back.
948 #[test]
949 fn encoding_is_deterministic() {
950 let original = cover(10_000, 3);
951 let costs = vec![1.0f32; original.len()];
952 let payload = b"determinism";
953
954 let mut first = original.clone();
955 let mut second = original.clone();
956
957 let changes_first = stc_encode_safe(&mut first, &costs, payload, &StcConfig::new(SEED));
958 let changes_second = stc_encode_safe(&mut second, &costs, payload, &StcConfig::new(SEED));
959
960 match (changes_first, changes_second) {
961 (Ok(first_count), Ok(second_count)) => assert_eq!(first_count, second_count),
962 (first_result, second_result) => {
963 panic!("both runs must succeed: {first_result:?} and {second_result:?}")
964 }
965 }
966
967 assert_eq!(first, second);
968 assert_ne!(first, original, "something must have been embedded");
969 }
970
971 /// TEST 3 — the Viterbi pass spends the cheap positions and spares the
972 /// expensive ones.
973 ///
974 /// The cost pattern alternates position by position rather than splitting the
975 /// cover into an expensive half and a cheap one. That is not a softening of
976 /// the test but a property of the code being tested: the parity-check matrix
977 /// is banded, so each message bit is satisfied by changes inside its own
978 /// block of positions and cannot be paid for a million positions away. A
979 /// half-and-half map would measure the block layout, not the cost model. An
980 /// alternating map puts both price levels inside every block, which is
981 /// exactly where the trellis is free to choose — and where a Viterbi pass
982 /// that ignored `rho` would show up immediately.
983 #[test]
984 fn changes_follow_the_cheap_positions() {
985 let mut pixels = cover(20_000, 4);
986 let original = pixels.clone();
987
988 let costs: Vec<f32> = (0..pixels.len())
989 .map(|index| if index % 2 == 0 { 1000.0 } else { 0.001 })
990 .collect();
991
992 let payload: Vec<u8> = (0..40u8).collect();
993 let config = StcConfig::new(SEED);
994
995 if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
996 panic!("embedding must succeed before its choices can be judged: {error}");
997 }
998
999 let (expensive, cheap) = original
1000 .iter()
1001 .zip(pixels.iter())
1002 .enumerate()
1003 .filter(|(_, (before, after))| before != after)
1004 .fold((0usize, 0usize), |(expensive, cheap), (index, _)| {
1005 if index % 2 == 0 {
1006 (expensive + 1, cheap)
1007 } else {
1008 (expensive, cheap + 1)
1009 }
1010 });
1011
1012 assert!(cheap > 0, "the payload must have cost something to embed");
1013 assert!(
1014 expensive * 10 <= cheap,
1015 "the trellis ignored the cost map: {expensive} changes at cost 1000.0 against {cheap} \
1016 at cost 0.001"
1017 );
1018 }
1019
1020 /// TEST 3b — the `±1` operator never overwrites, and never leaves the range.
1021 ///
1022 /// Every changed sample must differ from its cover value by exactly one, in
1023 /// either direction, and both directions must actually occur: a coder that
1024 /// always added would reintroduce the very pairing the operator exists to
1025 /// break.
1026 #[test]
1027 fn changes_move_samples_by_exactly_one_level_in_both_directions() {
1028 let mut pixels = cover(20_000, 5);
1029 let original = pixels.clone();
1030 let costs = vec![1.0f32; pixels.len()];
1031 let payload: Vec<u8> = (0..40u8).map(|value| value.wrapping_mul(37)).collect();
1032
1033 if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &StcConfig::new(SEED)) {
1034 panic!("embedding must succeed: {error}");
1035 }
1036
1037 let mut up = 0usize;
1038 let mut down = 0usize;
1039
1040 for (before, after) in original.iter().zip(pixels.iter()) {
1041 match (i16::from(*after)) - (i16::from(*before)) {
1042 0 => {}
1043 1 => up += 1,
1044 -1 => down += 1,
1045 other => panic!("a sample moved by {other} levels, which is not a ±1 change"),
1046 }
1047 }
1048
1049 assert!(up > 0 && down > 0, "{up} increments against {down} decrements");
1050 }
1051
1052 /// TEST 4a — a cover and a cost map of different lengths are refused.
1053 #[test]
1054 fn mismatched_lengths_are_refused() {
1055 let mut pixels = vec![0u8; 10_000];
1056 let costs = vec![1.0f32; 9_999];
1057
1058 let error = stc_encode_safe(&mut pixels, &costs, b"test", &StcConfig::new(SEED));
1059
1060 assert!(
1061 matches!(
1062 error,
1063 Err(StcError::LengthMismatch {
1064 pixels: 10_000,
1065 costs: 9_999
1066 })
1067 ),
1068 "expected a length mismatch, got: {error:?}"
1069 );
1070 }
1071
1072 /// TEST 4b — a payload over the `max_bpp` ceiling is refused, on both paths.
1073 #[test]
1074 fn oversized_payloads_are_refused() {
1075 // Twenty bytes is 160 bits against the 20 the ceiling allows here.
1076 let mut pixels = vec![0u8; 1_000];
1077 let costs = vec![1.0f32; pixels.len()];
1078 let config = StcConfig::new(SEED);
1079
1080 let encoding = stc_encode_safe(&mut pixels, &costs, &[0u8; 20], &config);
1081 assert!(
1082 matches!(
1083 encoding,
1084 Err(StcError::PayloadExceedsCapacity {
1085 payload_bits: 160,
1086 capacity_bits: 20
1087 })
1088 ),
1089 "expected the ceiling to refuse the payload, got: {encoding:?}"
1090 );
1091
1092 let decoding = stc_decode_safe(&pixels, 160, &config);
1093 assert!(
1094 matches!(decoding, Err(StcError::PayloadExceedsCapacity { .. })),
1095 "expected the ceiling to refuse the request, got: {decoding:?}"
1096 );
1097
1098 assert!(
1099 pixels.iter().all(|&sample| sample == 0),
1100 "a refused payload must leave the cover untouched"
1101 );
1102 }
1103
1104 /// TEST 4c — a cost map with a negative or non-finite entry is refused.
1105 #[test]
1106 fn unusable_cost_maps_are_refused() {
1107 let config = StcConfig::new(SEED);
1108
1109 for poison in [f32::NAN, f32::INFINITY, -1.0] {
1110 let mut pixels = vec![0u8; 10_000];
1111 let mut costs = vec![1.0f32; pixels.len()];
1112 if let Some(slot) = costs.get_mut(4_242) {
1113 *slot = poison;
1114 }
1115
1116 let error = stc_encode_safe(&mut pixels, &costs, b"test", &config);
1117
1118 assert!(
1119 matches!(error, Err(StcError::InvalidCostMap)),
1120 "expected a cost of {poison} to be refused, got: {error:?}"
1121 );
1122 }
1123 }
1124
1125 /// TEST 4d — a trellis height outside the supported range is refused rather
1126 /// than turned into an allocation nobody can serve.
1127 #[test]
1128 fn unsupported_trellis_heights_are_refused() {
1129 let mut pixels = vec![0u8; 10_000];
1130 let costs = vec![1.0f32; pixels.len()];
1131
1132 let mut config = StcConfig::new(SEED);
1133 config.trellis_height = MAX_TRELLIS_HEIGHT + 1;
1134
1135 let encoding = stc_encode_safe(&mut pixels, &costs, b"test", &config);
1136 assert!(
1137 matches!(encoding, Err(StcError::EncodingError(_))),
1138 "expected an oversized height to be refused, got: {encoding:?}"
1139 );
1140
1141 let decoding = stc_decode_safe(&pixels, 32, &config);
1142 assert!(
1143 matches!(decoding, Err(StcError::DecodingError(_))),
1144 "expected an oversized height to be refused, got: {decoding:?}"
1145 );
1146 }
1147
1148 /// An empty payload is a no-op on both paths rather than an error.
1149 #[test]
1150 fn an_empty_payload_changes_nothing() {
1151 let mut pixels = cover(10_000, 6);
1152 let original = pixels.clone();
1153 let costs = vec![1.0f32; pixels.len()];
1154 let config = StcConfig::new(SEED);
1155
1156 assert!(matches!(
1157 stc_encode_safe(&mut pixels, &costs, &[], &config),
1158 Ok(0)
1159 ));
1160 assert_eq!(pixels, original);
1161
1162 match stc_decode_safe(&pixels, 0, &config) {
1163 Ok(recovered) => assert!(recovered.is_empty()),
1164 Err(error) => panic!("decoding nothing must succeed: {error}"),
1165 }
1166 }
1167
1168 /// A different seed builds a different matrix, so the payload does not come
1169 /// back out.
1170 ///
1171 /// The property the whole extraction path rests on: without `stc_seed` there
1172 /// is no matrix, and without the matrix the syndrome of the container says
1173 /// nothing.
1174 #[test]
1175 fn the_wrong_seed_recovers_nothing() {
1176 let mut pixels = cover(10_000, 7);
1177 let costs = vec![1.0f32; pixels.len()];
1178 let payload = b"secret!!";
1179
1180 if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &StcConfig::new(SEED)) {
1181 panic!("embedding must succeed: {error}");
1182 }
1183
1184 let recovered = stc_decode_safe(&pixels, payload.len() * 8, &StcConfig::new([0xA5u8; 32]));
1185
1186 match recovered {
1187 Ok(bytes) => assert_ne!(bytes.as_slice(), payload.as_slice()),
1188 Err(error) => panic!("a wrong seed must decode to noise, not fail: {error}"),
1189 }
1190 }
1191
1192 /// Only the carrier bit of a sample is read back.
1193 ///
1194 /// The decoder is handed the stego samples with every bit above the first
1195 /// scrambled; the payload must still come out, because the syndrome is a
1196 /// function of the least significant bits alone.
1197 #[test]
1198 fn decoding_reads_nothing_but_the_carrier_bit() {
1199 let mut pixels = cover(10_000, 8);
1200 let costs = vec![1.0f32; pixels.len()];
1201 let payload = b"carrier";
1202 let config = StcConfig::new(SEED);
1203
1204 if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &config) {
1205 panic!("embedding must succeed: {error}");
1206 }
1207
1208 let scrambled: Vec<u8> = pixels
1209 .iter()
1210 .map(|sample| (sample & 1) | (sample.rotate_left(3) & !1))
1211 .collect();
1212
1213 match stc_decode_safe(&scrambled, payload.len() * 8, &config) {
1214 Ok(recovered) => assert_eq!(recovered.as_slice(), payload.as_slice()),
1215 Err(error) => panic!("decoding must ignore the upper bits: {error}"),
1216 }
1217 }
1218}