use std::fmt;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use chacha20::ChaCha20;
use zeroize::{ZeroizeOnDrop, Zeroizing};
pub const MAX_BPP: f32 = 0.02;
pub const DEFAULT_TRELLIS_HEIGHT: u32 = 10;
const MIN_TRELLIS_HEIGHT: u32 = 2;
const MAX_TRELLIS_HEIGHT: u32 = 20;
const H_MATRIX_NONCE: [u8; 12] = [1u8; 12];
const SIGN_NONCE: [u8; 12] = [2u8; 12];
const MAX_BLOCK_WIDTH: usize = 64;
const MAX_SEGMENT_COLUMNS: usize = 1 << 16;
const KEYSTREAM_BUFFER_BYTES: usize = 512;
const BITS_PER_BYTE: usize = 8;
#[derive(Debug)]
pub enum StcError {
LengthMismatch {
pixels: usize,
costs: usize,
},
PayloadExceedsCapacity {
payload_bits: usize,
capacity_bits: usize,
},
InvalidCostMap,
EncodingError(String),
DecodingError(String),
}
impl fmt::Display for StcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StcError::LengthMismatch { pixels, costs } => write!(
f,
"cover and cost vectors disagree: {pixels} positions against {costs} costs"
),
StcError::PayloadExceedsCapacity {
payload_bits,
capacity_bits,
} => write!(
f,
"payload of {payload_bits} bits exceeds the {capacity_bits} bits this cover may \
carry"
),
StcError::InvalidCostMap => write!(
f,
"the cost map contains a negative or non-finite value and cannot be used"
),
StcError::EncodingError(message) => write!(f, "the stc coder failed: {message}"),
StcError::DecodingError(message) => write!(f, "the stc decoder failed: {message}"),
}
}
}
impl std::error::Error for StcError {}
#[derive(ZeroizeOnDrop)]
pub struct StcConfig {
pub(crate) stc_seed: [u8; 32],
pub(crate) trellis_height: u32,
pub(crate) max_bpp: f32,
}
impl StcConfig {
pub fn new(stc_seed: [u8; 32]) -> Self {
Self {
stc_seed,
trellis_height: DEFAULT_TRELLIS_HEIGHT,
max_bpp: MAX_BPP,
}
}
pub fn stc_seed(&self) -> &[u8; 32] {
&self.stc_seed
}
pub fn trellis_height(&self) -> u32 {
self.trellis_height
}
pub fn max_bpp(&self) -> f32 {
self.max_bpp
}
pub fn capacity_bits(&self, positions: usize) -> usize {
(positions as f32 * self.max_bpp) as usize
}
}
impl fmt::Debug for StcConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StcConfig")
.field("stc_seed", &"[redacted]")
.field("trellis_height", &self.trellis_height)
.field("max_bpp", &self.max_bpp)
.finish()
}
}
pub fn stc_encode_safe(
pixels: &mut [u8],
cost: &[f32],
payload: &[u8],
config: &StcConfig,
) -> Result<usize, StcError> {
if pixels.len() != cost.len() {
return Err(StcError::LengthMismatch {
pixels: pixels.len(),
costs: cost.len(),
});
}
let payload_bits = payload.len().saturating_mul(BITS_PER_BYTE);
let capacity_bits = config.capacity_bits(pixels.len());
if payload_bits > capacity_bits {
return Err(StcError::PayloadExceedsCapacity {
payload_bits,
capacity_bits,
});
}
if !cost.iter().all(|&value| value.is_finite() && value >= 0.0) {
return Err(StcError::InvalidCostMap);
}
if payload_bits == 0 {
return Ok(0);
}
let Some(height) = validated_height(config.trellis_height) else {
return Err(StcError::EncodingError(format!(
"a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
config.trellis_height
)));
};
let Some(layout) = TrellisLayout::plan(pixels.len(), payload_bits) else {
return Err(StcError::EncodingError(format!(
"a cover of {} positions cannot carry {payload_bits} message bits",
pixels.len()
)));
};
let message = unpack_bits(payload);
let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
let stego_bits = solve_trellis(pixels, cost, &message, &columns, &layout, height)?;
Ok(apply_changes(pixels, &stego_bits, &config.stc_seed))
}
pub fn stc_decode_safe(
pixels: &[u8],
payload_len_bits: usize,
config: &StcConfig,
) -> Result<Vec<u8>, StcError> {
let capacity_bits = config.capacity_bits(pixels.len());
if payload_len_bits > capacity_bits {
return Err(StcError::PayloadExceedsCapacity {
payload_bits: payload_len_bits,
capacity_bits,
});
}
if payload_len_bits == 0 {
return Ok(Vec::new());
}
let Some(height) = validated_height(config.trellis_height) else {
return Err(StcError::DecodingError(format!(
"a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
config.trellis_height
)));
};
let Some(layout) = TrellisLayout::plan(pixels.len(), payload_len_bits) else {
return Err(StcError::DecodingError(format!(
"a cover of {} positions cannot hold {payload_len_bits} message bits",
pixels.len()
)));
};
let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
let mut message = Zeroizing::new(vec![0u8; payload_len_bits]);
let mut register = 0u32;
let mut position = 0usize;
for block in 0..layout.message_bits {
if block % layout.blocks_per_segment == 0 {
register = 0;
}
let block_end = layout.block_start(block + 1);
while position < block_end {
if pixels.get(position).is_some_and(|sample| sample & 1 == 1) {
register ^= columns.get(position).copied().unwrap_or(0);
}
position += 1;
}
if let Some(slot) = message.get_mut(block) {
*slot = (register & 1) as u8;
}
register >>= 1;
}
Ok(pack_bits(&message))
}
#[derive(Debug, Clone, Copy)]
struct TrellisLayout {
message_bits: usize,
used_positions: usize,
blocks_per_segment: usize,
}
impl TrellisLayout {
fn plan(positions: usize, message_bits: usize) -> Option<Self> {
if message_bits == 0 {
return None;
}
let used_positions = positions.min(message_bits.saturating_mul(MAX_BLOCK_WIDTH));
if used_positions < message_bits {
return None;
}
let average_width = (used_positions / message_bits).max(1);
let blocks_per_segment = (MAX_SEGMENT_COLUMNS / average_width).max(1);
Some(Self {
message_bits,
used_positions,
blocks_per_segment,
})
}
fn block_start(&self, index: usize) -> usize {
let numerator = index as u128 * self.used_positions as u128;
(numerator / self.message_bits as u128) as usize
}
}
fn validated_height(height: u32) -> Option<u32> {
(MIN_TRELLIS_HEIGHT..=MAX_TRELLIS_HEIGHT)
.contains(&height)
.then_some(height)
}
fn parity_columns(seed: &[u8; 32], height: u32, count: usize) -> Vec<u32> {
let mask = (1u32 << height) - 1;
let forced = 1u32 | (1u32 << (height - 1));
let mut keystream = Keystream::new(seed, &H_MATRIX_NONCE);
let mut columns = Vec::with_capacity(count);
for _ in 0..count {
let draw = loop {
let value = keystream.next_u32() & mask;
if value != 0 {
break value;
}
};
columns.push(draw | forced);
}
columns
}
fn solve_trellis(
pixels: &[u8],
cost: &[f32],
message: &[u8],
columns: &[u32],
layout: &TrellisLayout,
height: u32,
) -> Result<Zeroizing<Vec<u8>>, StcError> {
let states = 1usize << height;
let half_states = states / 2;
let mut stego_bits = Zeroizing::new(vec![0u8; layout.used_positions]);
let mut current = vec![f32::INFINITY; states];
let mut next = vec![f32::INFINITY; states];
let mut first_block = 0usize;
while first_block < layout.message_bits {
let last_block = (first_block + layout.blocks_per_segment).min(layout.message_bits);
let segment_start = layout.block_start(first_block);
let segment_end = layout.block_start(last_block);
let mut survivors = Zeroizing::new(vec![
0u8;
(segment_end - segment_start)
.saturating_mul(states)
.div_ceil(BITS_PER_BYTE)
]);
current.iter_mut().for_each(|weight| *weight = f32::INFINITY);
if let Some(origin) = current.get_mut(0) {
*origin = 0.0;
}
let mut position = segment_start;
for block in first_block..last_block {
let block_end = layout.block_start(block + 1);
while position < block_end {
let column = columns.get(position).copied().unwrap_or(1) as usize;
let cover_bit = pixels.get(position).copied().unwrap_or(0) & 1;
let rho = cost.get(position).copied().unwrap_or(0.0);
let keep = if cover_bit == 0 { 0.0 } else { rho };
let flip = if cover_bit == 0 { rho } else { 0.0 };
let base = (position - segment_start) * states;
for (state, slot) in next.iter_mut().enumerate() {
let stay = current.get(state).copied().unwrap_or(f32::INFINITY) + keep;
let cross = current
.get(state ^ column)
.copied()
.unwrap_or(f32::INFINITY)
+ flip;
if cross < stay {
set_survivor(&mut survivors, base + state);
*slot = cross;
} else {
*slot = stay;
}
}
std::mem::swap(&mut current, &mut next);
position += 1;
}
let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
for folded in 0..half_states {
let survivor = current
.get(2 * folded + bit)
.copied()
.unwrap_or(f32::INFINITY);
if let Some(slot) = current.get_mut(folded) {
*slot = survivor;
}
}
for slot in current.iter_mut().skip(half_states) {
*slot = f32::INFINITY;
}
}
let (mut state, best) = current.iter().enumerate().fold(
(0usize, f32::INFINITY),
|(best_state, best_cost), (state, &weight)| {
if weight < best_cost {
(state, weight)
} else {
(best_state, best_cost)
}
},
);
if !best.is_finite() {
return Err(StcError::EncodingError(
"the trellis admits no path that satisfies the requested syndrome".to_owned(),
));
}
let mut position = segment_end;
for block in (first_block..last_block).rev() {
let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
state = state * 2 + bit;
let block_start = layout.block_start(block);
while position > block_start {
position -= 1;
let column = columns.get(position).copied().unwrap_or(1) as usize;
let base = (position - segment_start) * states;
if survivor(&survivors, base + state) {
if let Some(slot) = stego_bits.get_mut(position) {
*slot = 1;
}
state ^= column;
}
}
}
first_block = last_block;
}
Ok(stego_bits)
}
fn apply_changes(pixels: &mut [u8], stego_bits: &[u8], seed: &[u8; 32]) -> usize {
let mut signs = Keystream::new(seed, &SIGN_NONCE);
let mut changed = 0usize;
for (position, &target) in stego_bits.iter().enumerate() {
let Some(sample) = pixels.get_mut(position) else {
break;
};
if *sample & 1 == target & 1 {
continue;
}
*sample = match *sample {
0 => 1,
u8::MAX => u8::MAX - 1,
value if signs.next_bit() == 1 => value.saturating_add(1),
value => value.saturating_sub(1),
};
changed += 1;
}
changed
}
fn set_survivor(survivors: &mut [u8], index: usize) {
if let Some(byte) = survivors.get_mut(index / BITS_PER_BYTE) {
*byte |= 1 << (index % BITS_PER_BYTE);
}
}
fn survivor(survivors: &[u8], index: usize) -> bool {
survivors
.get(index / BITS_PER_BYTE)
.is_some_and(|byte| byte & (1 << (index % BITS_PER_BYTE)) != 0)
}
fn unpack_bits(packed: &[u8]) -> Zeroizing<Vec<u8>> {
let mut bits = Zeroizing::new(Vec::with_capacity(packed.len().saturating_mul(BITS_PER_BYTE)));
for byte in packed {
for shift in (0..BITS_PER_BYTE).rev() {
bits.push((byte >> shift) & 1);
}
}
bits
}
fn pack_bits(bits: &[u8]) -> Vec<u8> {
let mut packed = vec![0u8; bits.len().div_ceil(BITS_PER_BYTE)];
for (index, bit) in bits.iter().enumerate() {
if bit & 1 == 1 {
if let Some(byte) = packed.get_mut(index / BITS_PER_BYTE) {
*byte |= 1 << (BITS_PER_BYTE - 1 - index % BITS_PER_BYTE);
}
}
}
packed
}
#[derive(ZeroizeOnDrop)]
struct Keystream {
#[zeroize(skip)]
cipher: ChaCha20,
buffer: [u8; KEYSTREAM_BUFFER_BYTES],
cursor: usize,
reservoir: u8,
taken: u8,
}
impl Keystream {
fn new(seed: &[u8; 32], nonce: &[u8; 12]) -> Self {
Self {
cipher: ChaCha20::new(seed.into(), nonce.into()),
buffer: [0u8; KEYSTREAM_BUFFER_BYTES],
cursor: KEYSTREAM_BUFFER_BYTES,
reservoir: 0,
taken: u8::try_from(BITS_PER_BYTE).unwrap_or(8),
}
}
fn next_byte(&mut self) -> u8 {
if self.cursor >= self.buffer.len() {
self.refill();
}
let byte = self.buffer.get(self.cursor).copied().unwrap_or(0);
self.cursor += 1;
byte
}
fn next_u32(&mut self) -> u32 {
u32::from_le_bytes([
self.next_byte(),
self.next_byte(),
self.next_byte(),
self.next_byte(),
])
}
fn next_bit(&mut self) -> u8 {
if usize::from(self.taken) >= BITS_PER_BYTE {
self.reservoir = self.next_byte();
self.taken = 0;
}
let bit = (self.reservoir >> self.taken) & 1;
self.taken += 1;
bit
}
fn refill(&mut self) {
self.buffer = [0u8; KEYSTREAM_BUFFER_BYTES];
self.cipher.apply_keystream(&mut self.buffer);
self.cursor = 0;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
const SEED: [u8; 32] = [0x5Au8; 32];
fn cover(len: usize, seed: u64) -> Vec<u8> {
let mut rng = StdRng::seed_from_u64(seed);
(0..len).map(|_| rng.random()).collect()
}
#[test]
fn round_trip_recovers_the_payload() {
let mut pixels = cover(10_000, 1);
let costs = vec![1.0f32; pixels.len()];
let payload = b"test";
let config = StcConfig::new(SEED);
let changed = match stc_encode_safe(&mut pixels, &costs, payload, &config) {
Ok(changed) => changed,
Err(error) => panic!("embedding into a uniform cover must succeed: {error}"),
};
assert!(
changed > 0,
"a four-byte payload cannot be carried by a cover nothing was changed in"
);
let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
Ok(recovered) => recovered,
Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
};
assert_eq!(recovered.as_slice(), payload.as_slice());
}
#[test]
fn round_trip_recovers_a_payload_at_the_capacity_ceiling() {
let mut pixels = cover(40_000, 2);
let costs = vec![1.0f32; pixels.len()];
let payload: Vec<u8> = (0..90u16).map(|value| (value % 251) as u8).collect();
let config = StcConfig::new(SEED);
if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
panic!("a payload at the ceiling must still embed: {error}");
}
let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
Ok(recovered) => recovered,
Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
};
assert_eq!(recovered, payload);
}
#[test]
fn encoding_is_deterministic() {
let original = cover(10_000, 3);
let costs = vec![1.0f32; original.len()];
let payload = b"determinism";
let mut first = original.clone();
let mut second = original.clone();
let changes_first = stc_encode_safe(&mut first, &costs, payload, &StcConfig::new(SEED));
let changes_second = stc_encode_safe(&mut second, &costs, payload, &StcConfig::new(SEED));
match (changes_first, changes_second) {
(Ok(first_count), Ok(second_count)) => assert_eq!(first_count, second_count),
(first_result, second_result) => {
panic!("both runs must succeed: {first_result:?} and {second_result:?}")
}
}
assert_eq!(first, second);
assert_ne!(first, original, "something must have been embedded");
}
#[test]
fn changes_follow_the_cheap_positions() {
let mut pixels = cover(20_000, 4);
let original = pixels.clone();
let costs: Vec<f32> = (0..pixels.len())
.map(|index| if index % 2 == 0 { 1000.0 } else { 0.001 })
.collect();
let payload: Vec<u8> = (0..40u8).collect();
let config = StcConfig::new(SEED);
if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
panic!("embedding must succeed before its choices can be judged: {error}");
}
let (expensive, cheap) = original
.iter()
.zip(pixels.iter())
.enumerate()
.filter(|(_, (before, after))| before != after)
.fold((0usize, 0usize), |(expensive, cheap), (index, _)| {
if index % 2 == 0 {
(expensive + 1, cheap)
} else {
(expensive, cheap + 1)
}
});
assert!(cheap > 0, "the payload must have cost something to embed");
assert!(
expensive * 10 <= cheap,
"the trellis ignored the cost map: {expensive} changes at cost 1000.0 against {cheap} \
at cost 0.001"
);
}
#[test]
fn changes_move_samples_by_exactly_one_level_in_both_directions() {
let mut pixels = cover(20_000, 5);
let original = pixels.clone();
let costs = vec![1.0f32; pixels.len()];
let payload: Vec<u8> = (0..40u8).map(|value| value.wrapping_mul(37)).collect();
if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &StcConfig::new(SEED)) {
panic!("embedding must succeed: {error}");
}
let mut up = 0usize;
let mut down = 0usize;
for (before, after) in original.iter().zip(pixels.iter()) {
match (i16::from(*after)) - (i16::from(*before)) {
0 => {}
1 => up += 1,
-1 => down += 1,
other => panic!("a sample moved by {other} levels, which is not a ±1 change"),
}
}
assert!(up > 0 && down > 0, "{up} increments against {down} decrements");
}
#[test]
fn mismatched_lengths_are_refused() {
let mut pixels = vec![0u8; 10_000];
let costs = vec![1.0f32; 9_999];
let error = stc_encode_safe(&mut pixels, &costs, b"test", &StcConfig::new(SEED));
assert!(
matches!(
error,
Err(StcError::LengthMismatch {
pixels: 10_000,
costs: 9_999
})
),
"expected a length mismatch, got: {error:?}"
);
}
#[test]
fn oversized_payloads_are_refused() {
let mut pixels = vec![0u8; 1_000];
let costs = vec![1.0f32; pixels.len()];
let config = StcConfig::new(SEED);
let encoding = stc_encode_safe(&mut pixels, &costs, &[0u8; 20], &config);
assert!(
matches!(
encoding,
Err(StcError::PayloadExceedsCapacity {
payload_bits: 160,
capacity_bits: 20
})
),
"expected the ceiling to refuse the payload, got: {encoding:?}"
);
let decoding = stc_decode_safe(&pixels, 160, &config);
assert!(
matches!(decoding, Err(StcError::PayloadExceedsCapacity { .. })),
"expected the ceiling to refuse the request, got: {decoding:?}"
);
assert!(
pixels.iter().all(|&sample| sample == 0),
"a refused payload must leave the cover untouched"
);
}
#[test]
fn unusable_cost_maps_are_refused() {
let config = StcConfig::new(SEED);
for poison in [f32::NAN, f32::INFINITY, -1.0] {
let mut pixels = vec![0u8; 10_000];
let mut costs = vec![1.0f32; pixels.len()];
if let Some(slot) = costs.get_mut(4_242) {
*slot = poison;
}
let error = stc_encode_safe(&mut pixels, &costs, b"test", &config);
assert!(
matches!(error, Err(StcError::InvalidCostMap)),
"expected a cost of {poison} to be refused, got: {error:?}"
);
}
}
#[test]
fn unsupported_trellis_heights_are_refused() {
let mut pixels = vec![0u8; 10_000];
let costs = vec![1.0f32; pixels.len()];
let mut config = StcConfig::new(SEED);
config.trellis_height = MAX_TRELLIS_HEIGHT + 1;
let encoding = stc_encode_safe(&mut pixels, &costs, b"test", &config);
assert!(
matches!(encoding, Err(StcError::EncodingError(_))),
"expected an oversized height to be refused, got: {encoding:?}"
);
let decoding = stc_decode_safe(&pixels, 32, &config);
assert!(
matches!(decoding, Err(StcError::DecodingError(_))),
"expected an oversized height to be refused, got: {decoding:?}"
);
}
#[test]
fn an_empty_payload_changes_nothing() {
let mut pixels = cover(10_000, 6);
let original = pixels.clone();
let costs = vec![1.0f32; pixels.len()];
let config = StcConfig::new(SEED);
assert!(matches!(
stc_encode_safe(&mut pixels, &costs, &[], &config),
Ok(0)
));
assert_eq!(pixels, original);
match stc_decode_safe(&pixels, 0, &config) {
Ok(recovered) => assert!(recovered.is_empty()),
Err(error) => panic!("decoding nothing must succeed: {error}"),
}
}
#[test]
fn the_wrong_seed_recovers_nothing() {
let mut pixels = cover(10_000, 7);
let costs = vec![1.0f32; pixels.len()];
let payload = b"secret!!";
if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &StcConfig::new(SEED)) {
panic!("embedding must succeed: {error}");
}
let recovered = stc_decode_safe(&pixels, payload.len() * 8, &StcConfig::new([0xA5u8; 32]));
match recovered {
Ok(bytes) => assert_ne!(bytes.as_slice(), payload.as_slice()),
Err(error) => panic!("a wrong seed must decode to noise, not fail: {error}"),
}
}
#[test]
fn decoding_reads_nothing_but_the_carrier_bit() {
let mut pixels = cover(10_000, 8);
let costs = vec![1.0f32; pixels.len()];
let payload = b"carrier";
let config = StcConfig::new(SEED);
if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &config) {
panic!("embedding must succeed: {error}");
}
let scrambled: Vec<u8> = pixels
.iter()
.map(|sample| (sample & 1) | (sample.rotate_left(3) & !1))
.collect();
match stc_decode_safe(&scrambled, payload.len() * 8, &config) {
Ok(recovered) => assert_eq!(recovered.as_slice(), payload.as_slice()),
Err(error) => panic!("decoding must ignore the upper bits: {error}"),
}
}
}