Skip to main content

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}