use crate::error::{Error, Result};
use crate::jpeg_transcoder::{DctStegoF5, JpegTranscoder};
use crate::payload_v3::types::{AuthAlgorithm, ProtectionChannels, V3_MAGIC, V3_PAYLOAD_VERSION};
use crate::protected::constants::{SPLITMIX64_SEED, STEGO_OFFSET_SEED_1, STEGO_SPREAD_FACTOR};
use crate::protected::ecc;
use crate::protected::metadata_trap::MetadataTrapProtector;
use crate::resource_limits::ResourceLimits;
use crate::traits::Protector;
use crate::types::{ProtectionContext, ProtectionLevel, VerificationStatus};
use crc32fast::Hasher as Crc32Hasher;
use hmac::{Hmac, Mac};
use image::{DynamicImage, Rgba, RgbaImage};
use sha2::Sha256;
use std::borrow::Cow;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
const MIN_PAYLOAD_SIZE: usize = 28;
const ECC_PAYLOAD_SIZE_V1: usize = ecc::TOTAL_ECC_LEN + 4;
pub(crate) const V2_HEADER_SIZE: usize = 32;
const ECC_PAYLOAD_SIZE_V2: usize = V2_HEADER_SIZE * ecc::REPLICATION_FACTOR + 4;
pub(crate) const ECC_PAYLOAD_BITS_V2: usize = ECC_PAYLOAD_SIZE_V2 * 8;
const ECC_PAYLOAD_BITS: usize = ECC_PAYLOAD_SIZE_V1 * 8;
const V3_CRC_PAYLOAD_SIZE: usize = crate::payload_v3::types::V3_CORE_SIZE + 4;
const V3_CRC_PAYLOAD_BITS: usize = V3_CRC_PAYLOAD_SIZE * 8;
const V3_HMAC_PAYLOAD_SIZE: usize = crate::payload_v3::types::V3_CORE_SIZE + 16;
const V3_HMAC_PAYLOAD_BITS: usize = V3_HMAC_PAYLOAD_SIZE * 8;
const SUPPORTED_PAYLOAD_VERSIONS: &[u8] = &[1, 2, 3];
#[cfg(feature = "test-seeds")]
const FALLBACK_SEEDS: &[u64] = &[42, 0, 1, 12345, 99999, 123456789];
pub const DEFAULT_TILE_SIZE: u32 = 64;
#[derive(Debug)]
enum CandidateOutcome {
Valid(Vec<u8>),
Invalid(Vec<u8>),
NotFound,
}
#[allow(dead_code)]
pub const MIN_TILE_SIZE: u32 = 32;
pub fn tile_seed(master_seed: u64, tile_x: u32, tile_y: u32) -> u64 {
let mut z = master_seed;
z ^= (tile_x as u64).wrapping_mul(0x9E3779B97F4A7C15);
z ^= (tile_y as u64).wrapping_mul(0xBF58476D1CE4E5B9);
splitmix64(z)
}
#[inline(always)]
fn splitmix64(x: u64) -> u64 {
let mut z = x.wrapping_add(SPLITMIX64_SEED);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
pub struct SteganographyProtector {
limits: ResourceLimits,
}
impl SteganographyProtector {
pub fn new() -> Self {
Self {
limits: ResourceLimits::default(),
}
}
pub fn with_resource_limits(limits: ResourceLimits) -> Self {
Self { limits }
}
fn payload_within_limits(&self, bytes: &[u8]) -> bool {
bytes.len() <= self.limits.max_payload_bytes()
}
pub fn verify_payload(&self, img: &DynamicImage) -> bool {
self.verify_payload_with_key(img, &[]) == VerificationStatus::Verified
}
pub(crate) fn apply_dct_stego_bytes(
&self,
jpeg_bytes: &[u8],
ctx: &ProtectionContext,
) -> Result<Vec<u8>> {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return Err(Error::Steganography("Not a valid JPEG".to_string()));
}
if let Some(tile_size) = ctx.tile_size().filter(|&s| s > 0) {
return self.apply_dct_stego_bytes_tiled(jpeg_bytes, ctx, tile_size);
}
let seed = ctx.seed();
match JpegTranscoder::decode_coefficients(jpeg_bytes) {
Ok((header, coefficients)) => {
let canonical_jpeg = JpegTranscoder::encode_coefficients(&header, &coefficients)?;
let (mut header, coefficients) =
JpegTranscoder::decode_coefficients(&canonical_jpeg)?;
let payload = self.generate_payload(ctx);
let redundancy = ctx.effective_redundancy();
let payload_bits = payload.len().saturating_mul(8);
let available_coeffs = Self::dct_payload_capacity(&coefficients);
DctStegoF5::new().embed_seed_in_quantization_tables(&mut header, seed)?;
if available_coeffs >= payload_bits {
for r in (1..=redundancy).rev() {
if payload_bits.saturating_mul(r) > available_coeffs {
continue;
}
let mut working_coefficients = coefficients.clone();
for _ in 0..4 {
let mut attempt_coefficients = working_coefficients.clone();
if DctStegoF5::with_redundancy(r)
.embed_f5(&mut attempt_coefficients, &payload, seed)
.is_err()
{
break;
}
let attempt_bytes = JpegTranscoder::encode_coefficients(
&header,
&attempt_coefficients,
)?;
if let Ok((_, roundtrip_coefficients)) =
JpegTranscoder::decode_coefficients(&attempt_bytes)
{
let roundtrip_bits = DctStegoF5::with_redundancy(r).extract_f5(
&roundtrip_coefficients,
payload_bits,
seed,
);
if Self::bits_to_bytes(&roundtrip_bits) == payload {
return Ok(attempt_bytes);
}
working_coefficients = roundtrip_coefficients;
} else {
break;
}
}
}
}
Ok(JpegTranscoder::encode_coefficients(&header, &coefficients)?)
}
Err(_) => {
let mut header = crate::jpeg_transcoder::JpegHeader::parse(jpeg_bytes)?;
DctStegoF5::new().embed_seed_in_quantization_tables(&mut header, seed)?;
Self::reassemble_jpeg_with_qtables(jpeg_bytes, &header)
}
}
}
pub(crate) fn lsb_pixels_needed(ctx: &ProtectionContext) -> usize {
let payload_bits = Self::payload_bits_for_context(ctx);
Self::lsb_pixels_needed_for_bits(payload_bits)
}
fn payload_bits_for_context(ctx: &ProtectionContext) -> usize {
if ctx.mac_key().is_some() {
V3_HMAC_PAYLOAD_BITS
} else {
V3_CRC_PAYLOAD_BITS
}
}
fn lsb_pixels_needed_for_bits(payload_bits: usize) -> usize {
payload_bits.div_ceil(3) * STEGO_SPREAD_FACTOR
}
pub(crate) fn apply_qtable_seed_bytes(&self, jpeg_bytes: &[u8], seed: u64) -> Result<Vec<u8>> {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return Err(Error::Steganography("Not a valid JPEG".to_string()));
}
let mut header = crate::jpeg_transcoder::JpegHeader::parse(jpeg_bytes)?;
DctStegoF5::new().embed_seed_in_quantization_tables(&mut header, seed)?;
Self::reassemble_jpeg_with_qtables(jpeg_bytes, &header)
}
pub(crate) fn apply_dct_stego_bytes_tiled(
&self,
jpeg_bytes: &[u8],
ctx: &ProtectionContext,
tile_size: u32,
) -> Result<Vec<u8>> {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return Err(Error::Steganography("Not a valid JPEG".to_string()));
}
let seed = ctx.seed();
match JpegTranscoder::decode_coefficients(jpeg_bytes) {
Ok((header, coefficients)) => {
let canonical_jpeg = JpegTranscoder::encode_coefficients(&header, &coefficients)?;
let (mut header, mut coefficients) =
JpegTranscoder::decode_coefficients(&canonical_jpeg)?;
let payload = self.generate_payload(ctx);
DctStegoF5::new().embed_seed_in_quantization_tables(&mut header, seed)?;
let max_h = header
.components
.iter()
.map(|c| c.h_sampling as u32)
.max()
.unwrap_or(1);
let max_v = header
.components
.iter()
.map(|c| c.v_sampling as u32)
.max()
.unwrap_or(1);
let luma_blocks_x = (header.width as u32 + max_h * 7) / (max_h * 8);
let luma_blocks_y = (header.height as u32 + max_v * 7) / (max_v * 8);
let blocks_per_tile = tile_size / 8;
let tiles_x = luma_blocks_x / blocks_per_tile;
let tiles_y = luma_blocks_y / blocks_per_tile;
let mut embedded_any = false;
for ty in 0..tiles_y {
for tx in 0..tiles_x {
let tile_blocks =
DctStegoF5::tile_block_set(&header, &coefficients, tx, ty, tile_size);
if tile_blocks.is_empty() {
continue;
}
let local_seed = tile_seed(seed, tx, ty);
if DctStegoF5::with_redundancy(1)
.embed_f5_in_blocks(
&mut coefficients,
&payload,
local_seed,
&tile_blocks,
)
.is_ok()
{
embedded_any = true;
}
}
}
if embedded_any {
let attempt_bytes =
JpegTranscoder::encode_coefficients(&header, &coefficients)?;
if let Ok((_, roundtrip_coefficients)) =
JpegTranscoder::decode_coefficients(&attempt_bytes)
{
let tile_blocks = DctStegoF5::tile_block_set(
&header,
&roundtrip_coefficients,
0,
0,
tile_size,
);
let roundtrip_bits = DctStegoF5::with_redundancy(1).extract_f5_from_blocks(
&roundtrip_coefficients,
payload.len() * 8,
tile_seed(seed, 0, 0),
&tile_blocks,
);
if Self::bits_to_bytes(&roundtrip_bits) == payload {
return Ok(attempt_bytes);
}
}
}
Ok(JpegTranscoder::encode_coefficients(&header, &coefficients)?)
}
Err(_) => {
let mut header = crate::jpeg_transcoder::JpegHeader::parse(jpeg_bytes)?;
DctStegoF5::new().embed_seed_in_quantization_tables(&mut header, seed)?;
Self::reassemble_jpeg_with_qtables(jpeg_bytes, &header)
}
}
}
#[doc(hidden)]
pub fn extract_f5_tiled_candidates(
&self,
jpeg_bytes: &[u8],
master_seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> Option<Vec<u8>> {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let (header, coefficients) = JpegTranscoder::decode_coefficients(jpeg_bytes).ok()?;
let max_h = header
.components
.iter()
.map(|c| c.h_sampling as u32)
.max()
.unwrap_or(1);
let max_v = header
.components
.iter()
.map(|c| c.v_sampling as u32)
.max()
.unwrap_or(1);
let luma_blocks_x = (header.width as u32 + max_h * 7) / (max_h * 8);
let luma_blocks_y = (header.height as u32 + max_v * 7) / (max_v * 8);
let blocks_per_tile = tile_size / 8;
let tiles_x = luma_blocks_x / blocks_per_tile;
let tiles_y = luma_blocks_y / blocks_per_tile;
let max_grid = 16u32;
let mut origins_tried = 0u32;
for ty in 0..tiles_y {
for tx in 0..tiles_x {
if origins_tried >= max_origins {
return None;
}
origins_tried += 1;
let tile_blocks =
DctStegoF5::tile_block_set(&header, &coefficients, tx, ty, tile_size);
if tile_blocks.is_empty() {
continue;
}
let base_x = tx;
let base_y = ty;
for dy in 0..=2u32 {
if base_y + dy >= max_grid {
break;
}
for dx in 0..=2u32 {
if base_x + dx >= max_grid {
break;
}
let local_seed = tile_seed(master_seed, base_x + dx, base_y + dy);
for &ecc_bits in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for redundancy in 1..=10 {
let stego = DctStegoF5::with_redundancy(redundancy);
let extracted = stego.extract_f5_from_blocks(
&coefficients,
ecc_bits,
local_seed,
&tile_blocks,
);
if extracted.len() < ecc_bits {
continue;
}
let payload_bytes = Self::bits_to_bytes(&extracted);
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return Some(Self::truncate_to_actual_payload(&payload_bytes));
}
if Self::try_ecc_decode(&payload_bytes).is_some() {
return Some(payload_bytes);
}
}
}
}
}
}
}
None
}
fn verify_extract_f5_tiled(
&self,
jpeg_bytes: &[u8],
master_seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> CandidateOutcome {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return CandidateOutcome::NotFound;
}
let Ok((header, coefficients)) = JpegTranscoder::decode_coefficients(jpeg_bytes) else {
return CandidateOutcome::NotFound;
};
let max_h = header
.components
.iter()
.map(|c| c.h_sampling as u32)
.max()
.unwrap_or(1);
let max_v = header
.components
.iter()
.map(|c| c.v_sampling as u32)
.max()
.unwrap_or(1);
let luma_blocks_x = (header.width as u32 + max_h * 7) / (max_h * 8);
let luma_blocks_y = (header.height as u32 + max_v * 7) / (max_v * 8);
let blocks_per_tile = tile_size / 8;
let tiles_x = luma_blocks_x / blocks_per_tile;
let tiles_y = luma_blocks_y / blocks_per_tile;
let max_grid = 16u32;
let mut origins_tried = 0u32;
let mut last_invalid: Option<Vec<u8>> = None;
'outer: for ty in 0..tiles_y {
for tx in 0..tiles_x {
if origins_tried >= max_origins {
break 'outer;
}
origins_tried += 1;
let tile_blocks =
DctStegoF5::tile_block_set(&header, &coefficients, tx, ty, tile_size);
if tile_blocks.is_empty() {
continue;
}
let base_x = tx;
let base_y = ty;
for dy in 0..=2u32 {
if base_y + dy >= max_grid {
break;
}
for dx in 0..=2u32 {
if base_x + dx >= max_grid {
break;
}
let local_seed = tile_seed(master_seed, base_x + dx, base_y + dy);
for &ecc_bits in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for redundancy in 1..=10 {
let stego = DctStegoF5::with_redundancy(redundancy);
let extracted = stego.extract_f5_from_blocks(
&coefficients,
ecc_bits,
local_seed,
&tile_blocks,
);
if extracted.len() < ecc_bits {
continue;
}
let payload_bytes = Self::bits_to_bytes(&extracted);
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return CandidateOutcome::Valid(
Self::truncate_to_actual_payload(&payload_bytes),
);
}
if Self::try_ecc_decode(&payload_bytes).is_some() {
return CandidateOutcome::Valid(payload_bytes);
}
if last_invalid.is_none() {
last_invalid = Some(payload_bytes);
}
}
}
}
}
}
}
if let Some(payload) = last_invalid {
CandidateOutcome::Invalid(payload)
} else {
CandidateOutcome::NotFound
}
}
pub(crate) fn embed_lsb_minimal(
&self,
img: &DynamicImage,
ctx: &ProtectionContext,
) -> DynamicImage {
let payload = self.generate_payload(ctx);
let rgba = img.to_rgba8();
let format = ctx
.input_format()
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT);
let processed = match format {
crate::types::ImageOutputFormat::Png | crate::types::ImageOutputFormat::WebP => {
self.embed_lsb(&rgba, &payload, ctx.seed(), 1)
}
crate::types::ImageOutputFormat::Jpeg => {
if let Ok(encoded) = crate::util::image::encode_image(img, image::ImageFormat::Jpeg)
{
if let Ok(with_seed) = self.apply_qtable_seed_bytes(&encoded, ctx.seed()) {
if let Ok(stego_img) = image::load_from_memory(&with_seed) {
stego_img.to_rgba8()
} else {
rgba.clone()
}
} else {
rgba.clone()
}
} else {
rgba.clone()
}
}
};
DynamicImage::ImageRgba8(processed)
}
fn reassemble_jpeg_with_qtables(
jpeg_bytes: &[u8],
header: &crate::jpeg_transcoder::JpegHeader,
) -> Result<Vec<u8>> {
let mut output = Vec::with_capacity(jpeg_bytes.len() + 256);
output.extend_from_slice(&jpeg_bytes[0..2]);
let mut pos = 2;
let mut wrote_tables = false;
while pos + 4 <= jpeg_bytes.len() {
if jpeg_bytes[pos] != 0xFF {
pos += 1;
continue;
}
let marker = jpeg_bytes[pos + 1];
if marker == 0xDA || marker == 0xD9 {
output.extend_from_slice(&jpeg_bytes[pos..]);
break;
}
if marker == 0xDB {
let segment_len =
u16::from_be_bytes([jpeg_bytes[pos + 2], jpeg_bytes[pos + 3]]) as usize;
pos += 2 + segment_len;
if !wrote_tables {
for table in header.quantization_tables.iter().flatten() {
output.push(0xFF);
output.push(0xDB);
let table_data_len = if table.precision == 16 { 129 } else { 65 };
let total_len = table_data_len + 2;
output.extend_from_slice(&(total_len as u16).to_be_bytes());
let precision_bit = if table.precision == 16 { 1 } else { 0 };
output.push((precision_bit << 4) | table.table_id);
if table.precision == 8 {
for &val in &table.values {
output.push(val as u8);
}
} else {
for &val in &table.values {
output.extend_from_slice(&val.to_be_bytes());
}
}
}
wrote_tables = true;
}
continue;
}
if marker == 0x00 {
pos += 1;
continue;
}
let segment_len =
u16::from_be_bytes([jpeg_bytes[pos + 2], jpeg_bytes[pos + 3]]) as usize;
if pos + 2 + segment_len > jpeg_bytes.len() {
return Err(Error::Steganography(
"Malformed JPEG segment length exceeds buffer".into(),
));
}
output.extend_from_slice(&jpeg_bytes[pos..pos + 2 + segment_len]);
pos += 2 + segment_len;
}
Ok(output)
}
pub(crate) fn dct_payload_capacity(
coefficients: &crate::jpeg_transcoder::Coefficients,
) -> usize {
coefficients
.values()
.flat_map(|blocks| blocks.iter())
.map(|block| {
block
.iter()
.skip(1)
.filter(|&&coef| coef.abs() >= 2)
.count()
})
.sum()
}
fn extract_with_redundancy(
&self,
img: &RgbaImage,
seed: u64,
mac_key: &[u8],
) -> Option<Vec<u8>> {
for &ecc_bits in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for pass in 0..5 {
let offset_seed = seed.wrapping_mul(STEGO_OFFSET_SEED_1.wrapping_add(pass as u64));
if let Some(payload) = self.extract_lsb(img, ecc_bits, offset_seed) {
if Self::try_ecc_decode(&payload).is_some() {
return Some(payload);
}
if Self::verify_payload_integrity(&payload, mac_key) {
return Some(Self::truncate_to_actual_payload(&payload));
}
}
}
}
None
}
fn verify_extract_with_redundancy(
&self,
img: &RgbaImage,
seed: u64,
mac_key: &[u8],
) -> CandidateOutcome {
let mut last_invalid: Option<Vec<u8>> = None;
for &ecc_bits in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for pass in 0..5 {
let offset_seed = seed.wrapping_mul(STEGO_OFFSET_SEED_1.wrapping_add(pass as u64));
if let Some(payload) = self.extract_lsb(img, ecc_bits, offset_seed) {
if Self::try_ecc_decode(&payload).is_some() {
return CandidateOutcome::Valid(payload);
}
if Self::verify_payload_integrity(&payload, mac_key) {
return CandidateOutcome::Valid(Self::truncate_to_actual_payload(&payload));
}
if last_invalid.is_none() {
last_invalid = Some(payload);
}
}
}
}
match last_invalid {
Some(p) => CandidateOutcome::Invalid(p),
None => CandidateOutcome::NotFound,
}
}
pub fn verify_payload_with_key(
&self,
img: &DynamicImage,
mac_key: &[u8],
) -> VerificationStatus {
if let Ok(png_bytes) = crate::util::image::encode_image(img, image::ImageFormat::Png) {
self.verify_payload_from_bytes_with_key(&png_bytes, mac_key)
} else {
VerificationStatus::NotFound
}
}
pub fn verify_payload_from_bytes_with_key(
&self,
img_bytes: &[u8],
mac_key: &[u8],
) -> VerificationStatus {
let metadata_seed = MetadataTrapProtector::extract_seed_from_image(img_bytes);
if img_bytes.starts_with(&[0xFF, 0xD8]) {
match self.verify_extract_verified_dct(img_bytes, mac_key) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
if let Some(metadata_seed) = metadata_seed {
match self.verify_extract_dct_with_seed(img_bytes, metadata_seed, mac_key) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
}
return VerificationStatus::NotFound;
}
if let Some(metadata_seed) = metadata_seed {
if let Ok(img) = image::load_from_memory(img_bytes) {
match self.verify_payload_with_seed_outcome(&img, metadata_seed, mac_key) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
}
}
if let Ok(img) = image::load_from_memory(img_bytes) {
let rgba = img.to_rgba8();
if let Some(fallback_seed) = Self::extract_seed_lsb_fallback(&rgba) {
match self.verify_payload_with_seed_outcome(&img, fallback_seed, mac_key) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
}
for &seed in &[42u64, 0, 1, 12345, 99999, 123456789] {
match self.verify_tiled_extraction_outcome(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
}
}
#[cfg(feature = "test-seeds")]
if let Ok(img) = image::load_from_memory(img_bytes) {
for &seed in FALLBACK_SEEDS
.iter()
.take(self.limits.max_verification_seeds())
{
match self.verify_payload_with_seed_outcome(&img, seed, mac_key) {
CandidateOutcome::Valid(_) => return VerificationStatus::Verified,
CandidateOutcome::Invalid(_) => return VerificationStatus::Invalid,
CandidateOutcome::NotFound => {}
}
}
}
VerificationStatus::NotFound
}
pub fn verify_payload_from_bytes(&self, img_bytes: &[u8], seed: u64) -> bool {
if img_bytes.starts_with(&[0xFF, 0xD8]) {
if let Some(payload_bytes) = self.extract_verified_dct_payload(img_bytes, &[]) {
let header = if let Some(decoded) = Self::try_ecc_decode(&payload_bytes) {
decoded
} else {
payload_bytes
};
if let Some(embedded_seed) = Self::extract_embedded_seed(&header) {
if embedded_seed == seed {
return true;
}
}
}
}
if let Ok(img) = image::load_from_memory(img_bytes) {
return self.verify_payload_with_seed(&img, seed);
}
false
}
pub fn verify_payload_with_seed(&self, img: &DynamicImage, seed: u64) -> bool {
let rgba = img.to_rgba8();
if let Some(payload) = self.extract_with_redundancy(&rgba, seed, &[]) {
let header = if let Some(decoded) = Self::try_ecc_decode(&payload) {
decoded
} else {
payload.clone()
};
if Self::verify_checksum(&payload) {
if let Some(embedded_seed) = Self::extract_embedded_seed(&header) {
if embedded_seed == seed {
return true;
}
}
}
}
if let Ok(encoded) = crate::util::image::encode_image(img, image::ImageFormat::Png) {
if let Some(metadata_seed) = MetadataTrapProtector::extract_seed_from_image(&encoded) {
if metadata_seed != seed {
if let Some(payload) = self.extract_with_redundancy(&rgba, metadata_seed, &[]) {
let header = if let Some(decoded) = Self::try_ecc_decode(&payload) {
decoded
} else {
payload.clone()
};
if Self::verify_checksum(&payload) {
if let Some(embedded_seed) = Self::extract_embedded_seed(&header) {
if embedded_seed == seed {
return true;
}
}
}
}
}
}
}
if self.try_tiled_extraction_verify(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
&[],
) {
return true;
}
false
}
fn try_tiled_extraction_verify(
&self,
rgba: &RgbaImage,
seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> bool {
let Some(payload) =
self.extract_lsb_tiled_candidates(rgba, seed, tile_size, max_origins, mac_key)
else {
return false;
};
Self::verify_embedded_seed_matches(&payload, seed)
}
fn verify_payload_with_seed_outcome(
&self,
img: &DynamicImage,
seed: u64,
mac_key: &[u8],
) -> CandidateOutcome {
let rgba = img.to_rgba8();
match self.verify_extract_with_redundancy(&rgba, seed, mac_key) {
CandidateOutcome::Valid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
return CandidateOutcome::Valid(payload);
}
}
CandidateOutcome::Invalid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
return CandidateOutcome::Invalid(payload);
}
}
CandidateOutcome::NotFound => {}
}
if let Ok(encoded) = crate::util::image::encode_image(img, image::ImageFormat::Png) {
if let Some(metadata_seed) = MetadataTrapProtector::extract_seed_from_image(&encoded) {
if metadata_seed != seed {
match self.verify_extract_with_redundancy(&rgba, metadata_seed, mac_key) {
CandidateOutcome::Valid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
return CandidateOutcome::Valid(payload);
}
}
CandidateOutcome::Invalid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
return CandidateOutcome::Invalid(payload);
}
}
CandidateOutcome::NotFound => {}
}
}
}
}
self.verify_tiled_extraction_outcome(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
)
}
fn verify_tiled_extraction_outcome(
&self,
rgba: &RgbaImage,
seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> CandidateOutcome {
let outcome = self.verify_extract_lsb_tiled(rgba, seed, tile_size, max_origins, mac_key);
match outcome {
CandidateOutcome::Valid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
CandidateOutcome::Valid(payload)
} else {
CandidateOutcome::NotFound
}
}
CandidateOutcome::Invalid(payload) => {
if Self::verify_embedded_seed_matches(&payload, seed) {
CandidateOutcome::Invalid(payload)
} else {
CandidateOutcome::NotFound
}
}
CandidateOutcome::NotFound => CandidateOutcome::NotFound,
}
}
fn verify_extract_dct_with_seed(
&self,
jpeg_bytes: &[u8],
seed: u64,
mac_key: &[u8],
) -> CandidateOutcome {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return CandidateOutcome::NotFound;
}
if let Ok((_, coefficients)) = JpegTranscoder::decode_coefficients(jpeg_bytes) {
let coeffs_outcome =
self.verify_extract_dct_from_coefficients(&coefficients, seed, mac_key);
if let CandidateOutcome::Valid(payload) = &coeffs_outcome {
return CandidateOutcome::Valid(payload.clone());
}
let tiled_outcome = self.verify_extract_f5_tiled(
jpeg_bytes,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
);
if let CandidateOutcome::Valid(payload) = &tiled_outcome {
return CandidateOutcome::Valid(payload.clone());
}
match (&coeffs_outcome, &tiled_outcome) {
(CandidateOutcome::Invalid(p), _) | (_, CandidateOutcome::Invalid(p)) => {
return CandidateOutcome::Invalid(p.clone());
}
_ => {}
}
}
CandidateOutcome::NotFound
}
fn verify_embedded_seed_matches(payload: &[u8], expected_seed: u64) -> bool {
let header = if let Some(decoded) = Self::try_ecc_decode(payload) {
decoded
} else {
payload.to_vec()
};
if header.len() < 10 {
return false;
}
if header.len() >= 3
&& header[0] == V3_MAGIC[0]
&& header[1] == V3_MAGIC[1]
&& header[2] == V3_PAYLOAD_VERSION
{
if header.len() < 19 {
return false;
}
let embedded_seed = u64::from_le_bytes([
header[11], header[12], header[13], header[14], header[15], header[16], header[17],
header[18],
]);
return embedded_seed == expected_seed;
}
let embedded_seed = u64::from_le_bytes([
header[2], header[3], header[4], header[5], header[6], header[7], header[8], header[9],
]);
embedded_seed == expected_seed
}
pub fn extract_payload(&self, img: &DynamicImage) -> Option<StegoPayload> {
self.extract_payload_with_key(img, &[])
}
pub fn extract_payload_with_key(
&self,
img: &DynamicImage,
mac_key: &[u8],
) -> Option<StegoPayload> {
if let Ok(encoded) = crate::util::image::encode_image(img, image::ImageFormat::Png) {
if let Some(metadata_seed) = MetadataTrapProtector::extract_seed_from_image(&encoded) {
if let Some(payload) =
self.extract_payload_with_seed_and_key(img, metadata_seed, mac_key)
{
return Some(payload);
}
}
}
let rgba = img.to_rgba8();
if let Some(fallback_seed) = Self::extract_seed_lsb_fallback(&rgba) {
if let Some(payload) =
self.extract_payload_with_seed_and_key(img, fallback_seed, mac_key)
{
return Some(payload);
}
}
#[cfg(feature = "test-seeds")]
for &seed in FALLBACK_SEEDS
.iter()
.take(self.limits.max_verification_seeds())
{
if let Some(payload) = self.extract_payload_with_seed_and_key(img, seed, mac_key) {
return Some(payload);
}
}
None
}
fn truncate_to_actual_payload(payload: &[u8]) -> Vec<u8> {
if payload.len() >= 3
&& payload[0] == V3_MAGIC[0]
&& payload[1] == V3_MAGIC[1]
&& payload[2] == V3_PAYLOAD_VERSION
&& payload.len() >= crate::payload_v3::types::V3_CORE_SIZE
{
let total_length = u16::from_le_bytes([payload[4], payload[5]]) as usize;
if total_length <= payload.len() {
return payload[..total_length].to_vec();
}
}
payload.to_vec()
}
fn extract_embedded_seed(header: &[u8]) -> Option<u64> {
if header.len() < 10 {
return None;
}
if header.len() >= 19
&& header[0] == V3_MAGIC[0]
&& header[1] == V3_MAGIC[1]
&& header[2] == V3_PAYLOAD_VERSION
{
return Some(u64::from_le_bytes([
header[11], header[12], header[13], header[14], header[15], header[16], header[17],
header[18],
]));
}
Some(u64::from_le_bytes([
header[2], header[3], header[4], header[5], header[6], header[7], header[8], header[9],
]))
}
pub fn extract_payload_from_bytes_with_key(
&self,
img_bytes: &[u8],
mac_key: &[u8],
) -> Option<StegoPayload> {
let metadata_seed = MetadataTrapProtector::extract_seed_from_image(img_bytes);
if img_bytes.starts_with(&[0xFF, 0xD8]) {
if let Some(payload_bytes) = self.extract_verified_dct_payload(img_bytes, mac_key) {
if !self.payload_within_limits(&payload_bytes) {
return None;
}
if let Some(decoded) = Self::try_ecc_decode(&payload_bytes) {
if let Some(payload) = Self::parse_stego_payload(&decoded) {
return Some(payload);
}
}
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return Self::parse_stego_payload(&payload_bytes);
}
}
if let Some(metadata_seed) = metadata_seed {
if let Some(payload_bytes) = self.extract_f5_tiled_candidates(
img_bytes,
metadata_seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
) {
if !self.payload_within_limits(&payload_bytes) {
return None;
}
if let Some(decoded) = Self::try_ecc_decode(&payload_bytes) {
if let Some(payload) = Self::parse_stego_payload(&decoded) {
return Some(payload);
}
}
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return Self::parse_stego_payload(&payload_bytes);
}
}
}
return None;
}
if let Some(metadata_seed) = metadata_seed {
if let Ok(img) = image::load_from_memory(img_bytes) {
if let Some(payload) =
self.extract_payload_with_seed_and_key(&img, metadata_seed, mac_key)
{
return Some(payload);
}
}
}
if let Ok(img) = image::load_from_memory(img_bytes) {
let rgba = img.to_rgba8();
if let Some(fallback_seed) = Self::extract_seed_lsb_fallback(&rgba) {
if let Some(payload) =
self.extract_payload_with_seed_and_key(&img, fallback_seed, mac_key)
{
return Some(payload);
}
}
}
#[cfg(feature = "test-seeds")]
if let Ok(img) = image::load_from_memory(img_bytes) {
let rgba = img.to_rgba8();
for &seed in FALLBACK_SEEDS
.iter()
.take(self.limits.max_verification_seeds())
{
if let Some(payload) = self.extract_lsb_tiled_candidates(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
) {
if let Some(decoded) = Self::try_ecc_decode(&payload) {
if let Some(payload) = Self::parse_stego_payload(&decoded) {
return Some(payload);
}
}
if Self::verify_payload_integrity(&payload, mac_key) {
return Self::parse_stego_payload(&payload);
}
}
}
}
None
}
fn parse_stego_payload(payload: &[u8]) -> Option<StegoPayload> {
if payload.len() < 24 {
return None;
}
if payload.len() >= 3
&& payload[0] == V3_MAGIC[0]
&& payload[1] == V3_MAGIC[1]
&& payload[2] == V3_PAYLOAD_VERSION
{
return Self::parse_stego_payload_v3(payload);
}
let version = payload[0];
for &supported in SUPPORTED_PAYLOAD_VERSIONS {
if version == supported {
return match supported {
1 => Self::parse_stego_payload_v1(payload),
2 => Self::parse_stego_payload_v2(payload),
_ => None,
};
}
}
None
}
fn parse_stego_payload_v1(payload: &[u8]) -> Option<StegoPayload> {
let protection_level = payload[1];
let extracted_seed = u64::from_le_bytes([
payload[2], payload[3], payload[4], payload[5], payload[6], payload[7], payload[8],
payload[9],
]);
let intensity_raw = u16::from_le_bytes([payload[10], payload[11]]);
let intensity = intensity_raw as f32 / 100.0;
Some(StegoPayload {
protection_level,
seed: extracted_seed,
intensity,
version: 1,
content_hash: None,
dmi_value: None,
raw_payload: None,
})
}
fn parse_stego_payload_v2(payload: &[u8]) -> Option<StegoPayload> {
if payload.len() < V2_HEADER_SIZE {
return None;
}
let protection_level = payload[1];
let extracted_seed = u64::from_le_bytes([
payload[2], payload[3], payload[4], payload[5], payload[6], payload[7], payload[8],
payload[9],
]);
let intensity_raw = u16::from_le_bytes([payload[10], payload[11]]);
let intensity = intensity_raw as f32 / 100.0;
let content_hash = {
let mut hash = [0u8; 4];
hash.copy_from_slice(&payload[20..24]);
if hash != [0, 0, 0, 0] {
Some(hash)
} else {
None
}
};
let dmi_value = match payload[24] {
0 => None,
1 => Some(crate::types::DmiValue::Allowed),
2 => Some(crate::types::DmiValue::ProhibitedAiMlTraining),
3 => Some(crate::types::DmiValue::ProhibitedGenAiMlTraining),
4 => Some(crate::types::DmiValue::ProhibitedExceptSearchEngineIndexing),
5 => Some(crate::types::DmiValue::Prohibited),
6 => Some(crate::types::DmiValue::ProhibitedSeeConstraints),
_ => None,
};
Some(StegoPayload {
protection_level,
seed: extracted_seed,
intensity,
version: 2,
content_hash,
dmi_value,
raw_payload: None,
})
}
fn parse_stego_payload_v3(payload: &[u8]) -> Option<StegoPayload> {
if payload.len() < crate::payload_v3::types::V3_CORE_SIZE {
return None;
}
let extracted_seed = u64::from_le_bytes([
payload[11],
payload[12],
payload[13],
payload[14],
payload[15],
payload[16],
payload[17],
payload[18],
]);
let intensity_raw = u16::from_le_bytes([payload[19], payload[20]]);
let intensity = intensity_raw as f32 / 100.0;
let content_hash = {
let mut hash = [0u8; 4];
hash.copy_from_slice(&payload[21..25]);
if hash != [0, 0, 0, 0] {
Some(hash)
} else {
None
}
};
let dmi_value = match payload[10] {
0 => None,
1 => Some(crate::types::DmiValue::Allowed),
2 => Some(crate::types::DmiValue::ProhibitedAiMlTraining),
3 => Some(crate::types::DmiValue::ProhibitedGenAiMlTraining),
4 => Some(crate::types::DmiValue::ProhibitedExceptSearchEngineIndexing),
5 => Some(crate::types::DmiValue::Prohibited),
6 => Some(crate::types::DmiValue::ProhibitedSeeConstraints),
_ => None,
};
Some(StegoPayload {
protection_level: 2,
seed: extracted_seed,
intensity,
version: 3,
content_hash,
dmi_value,
raw_payload: None,
})
}
pub fn extract_payload_with_seed_and_key(
&self,
img: &DynamicImage,
seed: u64,
mac_key: &[u8],
) -> Option<StegoPayload> {
let rgba = img.to_rgba8();
if let Some(payload) = self.extract_with_redundancy(&rgba, seed, mac_key) {
if !self.payload_within_limits(&payload) {
return None;
}
if let Some(decoded) = Self::try_ecc_decode(&payload) {
let mut sp = Self::parse_stego_payload(&decoded)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
if Self::verify_payload_integrity(&payload, mac_key) {
let mut sp = Self::parse_stego_payload(&payload)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
}
if let Some(payload) = self.extract_lsb_tiled_candidates(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
) {
if let Some(decoded) = Self::try_ecc_decode(&payload) {
let mut sp = Self::parse_stego_payload(&decoded)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
if Self::verify_payload_integrity(&payload, mac_key) {
let mut sp = Self::parse_stego_payload(&payload)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
}
None
}
pub fn extract_payload_with_seed(&self, img: &DynamicImage, seed: u64) -> Option<StegoPayload> {
let rgba = img.to_rgba8();
if let Some(payload) = self.extract_with_redundancy(&rgba, seed, &[]) {
if let Some(decoded) = Self::try_ecc_decode(&payload) {
let mut sp = Self::parse_stego_payload(&decoded)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
let mut sp = Self::parse_stego_payload(&payload)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
if let Some(payload) = self.extract_lsb_tiled_candidates(
&rgba,
seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
&[],
) {
if let Some(decoded) = Self::try_ecc_decode(&payload) {
let mut sp = Self::parse_stego_payload(&decoded)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
let mut sp = Self::parse_stego_payload(&payload)?;
sp.raw_payload = Some(Self::truncate_to_actual_payload(&payload));
return Some(sp);
}
None
}
fn extract_verified_dct_payload(&self, jpeg_bytes: &[u8], mac_key: &[u8]) -> Option<Vec<u8>> {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
if let Ok((header, coefficients)) = JpegTranscoder::decode_coefficients(jpeg_bytes) {
if let Some(extracted_seed) =
DctStegoF5::new().extract_seed_from_quantization_tables(&header)
{
if let Some(result) = self.extract_verified_dct_payload_from_coefficients(
&coefficients,
extracted_seed,
mac_key,
) {
return Some(result);
}
if let Some(result) = self.extract_f5_tiled_candidates(
jpeg_bytes,
extracted_seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
) {
return Some(result);
}
}
}
None
}
fn verify_extract_verified_dct(&self, jpeg_bytes: &[u8], mac_key: &[u8]) -> CandidateOutcome {
if !jpeg_bytes.starts_with(&[0xFF, 0xD8]) {
return CandidateOutcome::NotFound;
}
if let Ok((header, coefficients)) = JpegTranscoder::decode_coefficients(jpeg_bytes) {
if let Some(extracted_seed) =
DctStegoF5::new().extract_seed_from_quantization_tables(&header)
{
let coeffs_outcome = self.verify_extract_dct_from_coefficients(
&coefficients,
extracted_seed,
mac_key,
);
if let CandidateOutcome::Valid(payload) = &coeffs_outcome {
return CandidateOutcome::Valid(payload.clone());
}
let tiled_outcome = self.verify_extract_f5_tiled(
jpeg_bytes,
extracted_seed,
DEFAULT_TILE_SIZE,
self.limits.max_tile_extraction_origins() as u32,
mac_key,
);
if let CandidateOutcome::Valid(payload) = &tiled_outcome {
return CandidateOutcome::Valid(payload.clone());
}
match (&coeffs_outcome, &tiled_outcome) {
(CandidateOutcome::Invalid(p), _) | (_, CandidateOutcome::Invalid(p)) => {
return CandidateOutcome::Invalid(p.clone());
}
_ => {}
}
}
}
CandidateOutcome::NotFound
}
fn extract_verified_dct_payload_from_coefficients(
&self,
coefficients: &crate::jpeg_transcoder::Coefficients,
seed: u64,
mac_key: &[u8],
) -> Option<Vec<u8>> {
for &bits_needed in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for redundancy in 1..=10 {
let stego_f5 = DctStegoF5::with_redundancy(redundancy);
let extracted = stego_f5.extract_f5(coefficients, bits_needed, seed);
if extracted.len() < bits_needed {
continue;
}
let payload_bytes = Self::bits_to_bytes(&extracted);
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return Some(Self::truncate_to_actual_payload(&payload_bytes));
}
if Self::try_ecc_decode(&payload_bytes).is_some() {
return Some(payload_bytes);
}
}
}
None
}
fn verify_extract_dct_from_coefficients(
&self,
coefficients: &crate::jpeg_transcoder::Coefficients,
seed: u64,
mac_key: &[u8],
) -> CandidateOutcome {
let mut last_invalid: Option<Vec<u8>> = None;
for &bits_needed in &[ECC_PAYLOAD_BITS_V2, ECC_PAYLOAD_BITS] {
for redundancy in 1..=10 {
let stego_f5 = DctStegoF5::with_redundancy(redundancy);
let extracted = stego_f5.extract_f5(coefficients, bits_needed, seed);
if extracted.len() < bits_needed {
continue;
}
let payload_bytes = Self::bits_to_bytes(&extracted);
if Self::verify_payload_integrity(&payload_bytes, mac_key) {
return CandidateOutcome::Valid(Self::truncate_to_actual_payload(
&payload_bytes,
));
}
if Self::try_ecc_decode(&payload_bytes).is_some() {
return CandidateOutcome::Valid(payload_bytes);
}
if last_invalid.is_none() {
last_invalid = Some(payload_bytes);
}
}
}
match last_invalid {
Some(p) => CandidateOutcome::Invalid(p),
None => CandidateOutcome::NotFound,
}
}
fn compute_payload_mac(payload_without_mac: &[u8], mac_key: &[u8]) -> [u8; 8] {
let mut mac = HmacSha256::new_from_slice(mac_key).expect("HMAC can take key of any size");
mac.update(payload_without_mac);
let result = mac.finalize().into_bytes();
[
result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],
]
}
fn compute_payload_mac_v3(payload_without_mac: &[u8], mac_key: &[u8]) -> [u8; 16] {
let mut mac = HmacSha256::new_from_slice(mac_key).expect("HMAC can take key of any size");
mac.update(payload_without_mac);
let result = mac.finalize().into_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&result[..16]);
out
}
fn verify_payload_mac(payload_without_mac: &[u8], mac_key: &[u8], expected_mac: &[u8]) -> bool {
let computed_mac = Self::compute_payload_mac(payload_without_mac, mac_key);
computed_mac.ct_eq(expected_mac).into()
}
fn compute_checksum(data: &[u8]) -> [u8; 4] {
let mut hasher = Crc32Hasher::new();
hasher.update(data);
hasher.finalize().to_le_bytes()
}
fn verify_checksum(payload: &[u8]) -> bool {
if payload.len() >= 3
&& payload[0] == V3_MAGIC[0]
&& payload[1] == V3_MAGIC[1]
&& payload[2] == V3_PAYLOAD_VERSION
{
if payload.len() < crate::payload_v3::types::V3_CORE_SIZE + 4 {
return false;
}
let total_length = u16::from_le_bytes([payload[4], payload[5]]) as usize;
if total_length > payload.len() {
return false;
}
let auth_tag_len = payload[30] as usize;
if total_length < crate::payload_v3::types::V3_CORE_SIZE + auth_tag_len {
return false;
}
let core_and_ext = &payload[..total_length - auth_tag_len];
let expected = Self::compute_checksum(core_and_ext);
payload[total_length - auth_tag_len] == expected[0]
&& payload[total_length - auth_tag_len + 1] == expected[1]
&& payload[total_length - auth_tag_len + 2] == expected[2]
&& payload[total_length - auth_tag_len + 3] == expected[3]
}
else {
let v2_ecc_len = V2_HEADER_SIZE * ecc::REPLICATION_FACTOR;
if payload.len() >= v2_ecc_len + 4 {
let expected = Self::compute_checksum(&payload[..v2_ecc_len]);
if payload[v2_ecc_len] == expected[0]
&& payload[v2_ecc_len + 1] == expected[1]
&& payload[v2_ecc_len + 2] == expected[2]
&& payload[v2_ecc_len + 3] == expected[3]
{
return true;
}
}
if payload.len() >= ecc::TOTAL_ECC_LEN + 4 {
let expected = Self::compute_checksum(&payload[..ecc::TOTAL_ECC_LEN]);
if payload[ecc::TOTAL_ECC_LEN] == expected[0]
&& payload[ecc::TOTAL_ECC_LEN + 1] == expected[1]
&& payload[ecc::TOTAL_ECC_LEN + 2] == expected[2]
&& payload[ecc::TOTAL_ECC_LEN + 3] == expected[3]
{
return true;
}
}
if payload.len() >= MIN_PAYLOAD_SIZE {
let expected = Self::compute_checksum(&payload[..24]);
if payload[24] == expected[0]
&& payload[25] == expected[1]
&& payload[26] == expected[2]
&& payload[27] == expected[3]
{
return true;
}
}
false
}
}
fn verify_payload_integrity(payload: &[u8], mac_key: &[u8]) -> bool {
if payload.len() >= 3
&& payload[0] == V3_MAGIC[0]
&& payload[1] == V3_MAGIC[1]
&& payload[2] == V3_PAYLOAD_VERSION
{
if payload.len() < crate::payload_v3::types::V3_CORE_SIZE {
return false;
}
let total_length = u16::from_le_bytes([payload[4], payload[5]]) as usize;
if total_length > payload.len() {
return false;
}
let auth_algo = payload[29];
let auth_tag_len = payload[30] as usize;
if total_length < crate::payload_v3::types::V3_CORE_SIZE + auth_tag_len {
return false;
}
let core_and_ext = &payload[..total_length - auth_tag_len];
let tag = &payload[total_length - auth_tag_len..total_length];
match auth_algo {
1 => {
let expected = Self::compute_checksum(core_and_ext);
tag == expected
}
2 if !mac_key.is_empty() => {
let mut mac =
HmacSha256::new_from_slice(mac_key).expect("HMAC can take key of any size");
mac.update(core_and_ext);
let result = mac.finalize().into_bytes();
result[..tag.len()].ct_eq(tag).into()
}
_ => false,
}
} else if mac_key.is_empty() {
Self::verify_checksum(payload)
} else {
if payload.len() >= V2_HEADER_SIZE + 8 {
Self::verify_payload_mac(
&payload[..V2_HEADER_SIZE],
mac_key,
&payload[V2_HEADER_SIZE..V2_HEADER_SIZE + 8],
)
}
else if payload.len() >= 32 {
Self::verify_payload_mac(&payload[..24], mac_key, &payload[24..32])
} else {
false
}
}
}
fn try_ecc_decode(payload: &[u8]) -> Option<Vec<u8>> {
for &data_len in &[V2_HEADER_SIZE, 24usize] {
let ecc_len = data_len * ecc::REPLICATION_FACTOR;
if payload.len() >= ecc_len {
if let Some(decoded) = ecc::ecc_decode(payload, data_len) {
if decoded.len() >= data_len {
let checksum_start = ecc_len;
let expected = Self::compute_checksum(&payload[..ecc_len]);
if payload[checksum_start] == expected[0]
&& payload[checksum_start + 1] == expected[1]
&& payload[checksum_start + 2] == expected[2]
&& payload[checksum_start + 3] == expected[3]
{
return Some(decoded);
}
}
}
}
}
None
}
fn generate_payload(&self, ctx: &ProtectionContext) -> Vec<u8> {
let intensity_val = (ctx.intensity() * 100.0) as u16;
let dmi_byte = ctx
.dmi_value()
.map(|d| match d {
crate::types::DmiValue::Unspecified => 0u8,
crate::types::DmiValue::Allowed => 1,
crate::types::DmiValue::ProhibitedAiMlTraining => 2,
crate::types::DmiValue::ProhibitedGenAiMlTraining => 3,
crate::types::DmiValue::ProhibitedExceptSearchEngineIndexing => 4,
crate::types::DmiValue::Prohibited => 5,
crate::types::DmiValue::ProhibitedSeeConstraints => 6,
})
.unwrap_or(0);
let content_hash_8 = ctx
.content_hash()
.map(|h| {
let mut buf = [0u8; 8];
buf[..4].copy_from_slice(&h);
buf
})
.unwrap_or([0u8; 8]);
let flags = crate::payload_v3::types::PayloadFlags {
has_extensions: false,
has_key_id: false,
tiled: ctx.is_tile_mode_enabled(),
progressive_jpeg: ctx.progressive_jpeg(),
critical_extension: false,
signed: false,
reserved: 0,
};
let channels = ProtectionChannels {
rights_metadata: true,
hidden_marker: true,
authentication: true,
};
let has_mac = ctx.mac_key().is_some();
let (auth_algo, auth_tag_len) = if has_mac {
(AuthAlgorithm::HmacSha256Truncated, 16u8)
} else {
(AuthAlgorithm::Crc32, 4u8)
};
let mut buf =
Vec::with_capacity(crate::payload_v3::types::V3_CORE_SIZE + auth_tag_len as usize);
buf.extend_from_slice(&V3_MAGIC);
buf.push(V3_PAYLOAD_VERSION);
buf.push(crate::payload_v3::types::V3_CORE_SIZE as u8);
let total_length = crate::payload_v3::types::V3_CORE_SIZE + auth_tag_len as usize;
buf.extend_from_slice(&(total_length as u16).to_le_bytes());
buf.extend_from_slice(&flags.to_bits().to_le_bytes());
buf.extend_from_slice(&channels.to_bits().to_le_bytes());
buf.push(dmi_byte);
buf.extend_from_slice(&ctx.seed().to_le_bytes());
buf.extend_from_slice(&intensity_val.to_le_bytes());
buf.extend_from_slice(&content_hash_8);
buf.push(auth_algo as u8);
buf.push(auth_tag_len);
buf.push(0);
debug_assert_eq!(buf.len(), crate::payload_v3::types::V3_CORE_SIZE);
let auth_tag = if let Some(key) = ctx.mac_key() {
Self::compute_payload_mac_v3(&buf, key).to_vec()
} else {
Self::compute_checksum(&buf).to_vec()
};
buf.extend_from_slice(&auth_tag);
buf
}
#[inline(always)]
fn stego_permutation(index: usize, total_pixels: usize, seed: u64) -> usize {
let a = splitmix64(seed).wrapping_mul(2) | 1;
let b = splitmix64(seed.wrapping_add(0x9e3779b97f4a7c15));
a.wrapping_mul(index as u64).wrapping_add(b) as usize % total_pixels
}
fn embed_lsb(
&self,
img: &RgbaImage,
payload: &[u8],
seed: u64,
redundancy: usize,
) -> RgbaImage {
let (width, height) = img.dimensions();
let mut output = img.clone();
let payload_bits = Self::bytes_to_bits(payload);
let total_pixels = (width * height) as usize;
let total_pixels_needed = Self::lsb_pixels_needed_for_bits(payload_bits.len());
if total_pixels_needed > total_pixels {
return output;
}
for pass in 0..redundancy {
let offset_seed = seed.wrapping_mul(STEGO_OFFSET_SEED_1.wrapping_add(pass as u64));
for (i, &bit) in payload_bits.iter().enumerate() {
let channel = i % 3;
for s in 0..STEGO_SPREAD_FACTOR {
let logical = i * STEGO_SPREAD_FACTOR + s;
let idx = Self::stego_permutation(logical, total_pixels, offset_seed);
let x = idx as u32 % width;
let y = idx as u32 / width;
Self::embed_bit_in_pixel(&mut output, x, y, channel, bit);
}
}
}
output
}
fn extract_lsb(&self, img: &RgbaImage, expected_bits: usize, seed: u64) -> Option<Vec<u8>> {
let (width, height) = img.dimensions();
let total_pixels = (width * height) as usize;
if expected_bits * STEGO_SPREAD_FACTOR > total_pixels * 3 {
return None;
}
let mut bits = Vec::with_capacity(expected_bits);
let threshold = (STEGO_SPREAD_FACTOR / 2) as u32;
for i in 0..expected_bits {
let channel = i % 3;
let mut ones = 0u32;
for s in 0..STEGO_SPREAD_FACTOR {
let logical = i * STEGO_SPREAD_FACTOR + s;
let idx = Self::stego_permutation(logical, total_pixels, seed);
let x = idx as u32 % width;
let y = idx as u32 / width;
let pixel = img.get_pixel(x, y);
let bit = match channel {
0 => pixel[0] & 1,
1 => pixel[1] & 1,
_ => pixel[2] & 1,
};
ones += bit as u32;
}
bits.push(if ones > threshold { 1 } else { 0 });
}
Some(Self::bits_to_bytes(&bits))
}
fn embed_lsb_tiled(
&self,
img: &RgbaImage,
payload: &[u8],
master_seed: u64,
tile_size: u32,
) -> RgbaImage {
let (width, height) = img.dimensions();
if tile_size == 0 || width < tile_size || height < tile_size {
return img.clone();
}
let mut output = img.clone();
let mut tile_y: u32 = 0;
while tile_y * tile_size < height {
let y0 = tile_y * tile_size;
let y1 = (y0 + tile_size).min(height);
let mut tile_x: u32 = 0;
while tile_x * tile_size < width {
let x0 = tile_x * tile_size;
let x1 = (x0 + tile_size).min(width);
let local_seed = tile_seed(master_seed, tile_x, tile_y);
let sub = Self::crop_rgba(&output, x0, y0, x1 - x0, y1 - y0);
let embedded = self.embed_lsb(&sub, payload, local_seed, 1);
Self::blit_rgba(&mut output, x0, y0, &embedded);
tile_x += 1;
}
tile_y += 1;
}
output
}
fn extract_lsb_tiled_candidates(
&self,
img: &RgbaImage,
master_seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> Option<Vec<u8>> {
if tile_size == 0 {
return None;
}
let (width, height) = img.dimensions();
if width < tile_size || height < tile_size {
return None;
}
let ecc_bits_list = [
ECC_PAYLOAD_BITS_V2,
ECC_PAYLOAD_BITS,
V3_CRC_PAYLOAD_BITS,
V3_HMAC_PAYLOAD_BITS,
];
let stride = (tile_size / 2).max(1);
let mut origins: Vec<(u32, u32)> = Vec::new();
let mut y = 0u32;
while y + tile_size <= height {
let mut x = 0u32;
while x + tile_size <= width {
origins.push((x, y));
if origins.len() as u32 >= max_origins {
break;
}
x = x.saturating_add(stride);
}
if origins.len() as u32 >= max_origins {
break;
}
y = y.saturating_add(stride);
}
let max_grid = 16u32;
let mut payload = None;
for &(x0, y0) in &origins {
let sub = Self::crop_rgba(img, x0, y0, tile_size, tile_size);
let base_x = x0 / tile_size;
let base_y = y0 / tile_size;
for dy in 0..=2u32 {
if base_y + dy >= max_grid {
break;
}
for dx in 0..=2u32 {
if base_x + dx >= max_grid {
break;
}
let local_seed = tile_seed(master_seed, base_x + dx, base_y + dy);
for &ecc_bits in &ecc_bits_list {
for pass in 0..5 {
let offset_seed = local_seed
.wrapping_mul(STEGO_OFFSET_SEED_1.wrapping_add(pass as u64));
if let Some(candidate) = self.extract_lsb(&sub, ecc_bits, offset_seed) {
if Self::try_ecc_decode(&candidate).is_some() {
payload = Some(candidate);
break;
}
if Self::verify_payload_integrity(&candidate, mac_key) {
payload = Some(Self::truncate_to_actual_payload(&candidate));
break;
}
}
}
if payload.is_some() {
break;
}
}
if payload.is_some() {
break;
}
}
if payload.is_some() {
break;
}
}
if payload.is_some() {
break;
}
}
payload
}
fn verify_extract_lsb_tiled(
&self,
img: &RgbaImage,
master_seed: u64,
tile_size: u32,
max_origins: u32,
mac_key: &[u8],
) -> CandidateOutcome {
if tile_size == 0 {
return CandidateOutcome::NotFound;
}
let (width, height) = img.dimensions();
if width < tile_size || height < tile_size {
return CandidateOutcome::NotFound;
}
let ecc_bits_list = [
ECC_PAYLOAD_BITS_V2,
ECC_PAYLOAD_BITS,
V3_CRC_PAYLOAD_BITS,
V3_HMAC_PAYLOAD_BITS,
];
let stride = (tile_size / 2).max(1);
let mut origins: Vec<(u32, u32)> = Vec::new();
let mut y = 0u32;
while y + tile_size <= height {
let mut x = 0u32;
while x + tile_size <= width {
origins.push((x, y));
if origins.len() as u32 >= max_origins {
break;
}
x = x.saturating_add(stride);
}
if origins.len() as u32 >= max_origins {
break;
}
y = y.saturating_add(stride);
}
let max_grid = 16u32;
let mut last_invalid: Option<Vec<u8>> = None;
for &(x0, y0) in &origins {
let sub = Self::crop_rgba(img, x0, y0, tile_size, tile_size);
let base_x = x0 / tile_size;
let base_y = y0 / tile_size;
for dy in 0..=2u32 {
if base_y + dy >= max_grid {
break;
}
for dx in 0..=2u32 {
if base_x + dx >= max_grid {
break;
}
let local_seed = tile_seed(master_seed, base_x + dx, base_y + dy);
for &ecc_bits in &ecc_bits_list {
for pass in 0..5 {
let offset_seed = local_seed
.wrapping_mul(STEGO_OFFSET_SEED_1.wrapping_add(pass as u64));
if let Some(candidate) = self.extract_lsb(&sub, ecc_bits, offset_seed) {
if Self::try_ecc_decode(&candidate).is_some() {
return CandidateOutcome::Valid(candidate);
}
if Self::verify_payload_integrity(&candidate, mac_key) {
return CandidateOutcome::Valid(
Self::truncate_to_actual_payload(&candidate),
);
}
if last_invalid.is_none() {
last_invalid = Some(candidate);
}
}
}
}
}
}
}
match last_invalid {
Some(p) => CandidateOutcome::Invalid(p),
None => CandidateOutcome::NotFound,
}
}
fn crop_rgba(src: &RgbaImage, x: u32, y: u32, w: u32, h: u32) -> RgbaImage {
let mut out = RgbaImage::new(w, h);
for dy in 0..h {
for dx in 0..w {
let p = src.get_pixel(x + dx, y + dy);
out.put_pixel(dx, dy, *p);
}
}
out
}
fn blit_rgba(dst: &mut RgbaImage, x: u32, y: u32, src: &RgbaImage) {
let (w, h) = src.dimensions();
for dy in 0..h {
for dx in 0..w {
let p = src.get_pixel(dx, dy);
dst.put_pixel(x + dx, y + dy, *p);
}
}
}
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
let mut bits = Vec::with_capacity(bytes.len() * 8);
for byte in bytes {
for i in 0..8 {
bits.push((byte >> i) & 1);
}
}
bits
}
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
if !bits.len().is_multiple_of(8) {
return Vec::new();
}
let mut bytes = Vec::with_capacity(bits.len() / 8);
for chunk in bits.chunks_exact(8) {
let mut byte = 0u8;
for (i, &bit) in chunk.iter().enumerate() {
byte |= bit << i;
}
bytes.push(byte);
}
bytes
}
fn embed_bit_in_pixel(output: &mut RgbaImage, x: u32, y: u32, channel: usize, bit: u8) {
let pixel = output.get_pixel(x, y);
let old_val = pixel[channel];
if (old_val & 1) == bit {
return;
}
let direction_hash = x.wrapping_mul(31).wrapping_add(y.wrapping_mul(17));
let new_val = if direction_hash & 1 == 0 {
old_val.wrapping_add(1)
} else {
old_val.wrapping_sub(1)
};
let new_pixel = Rgba([
if channel == 0 { new_val } else { pixel[0] },
if channel == 1 { new_val } else { pixel[1] },
if channel == 2 { new_val } else { pixel[2] },
pixel[3],
]);
output.put_pixel(x, y, new_pixel);
}
fn embed_seed_lsb_fallback(img: &mut RgbaImage, seed: u64) {
let (width, height) = img.dimensions();
let total_channels = (width * height * 3) as usize;
if total_channels < 64 {
return;
}
let seed_bytes = seed.to_le_bytes();
let mut channel_idx = 0;
for &byte in &seed_bytes {
for bit in 0..8 {
let pixel_offset = channel_idx / 3;
let channel = channel_idx % 3;
let x = pixel_offset as u32 % width;
let y = pixel_offset as u32 / width;
let bit_val = (byte >> bit) & 1;
let pixel = img.get_pixel(x, y);
let old_val = pixel[channel];
if (old_val & 1) != bit_val {
let direction_hash = x.wrapping_mul(31).wrapping_add(y.wrapping_mul(17));
let new_val = if direction_hash & 1 == 0 {
old_val.wrapping_add(1)
} else {
old_val.wrapping_sub(1)
};
let new_pixel = Rgba([
if channel == 0 { new_val } else { pixel[0] },
if channel == 1 { new_val } else { pixel[1] },
if channel == 2 { new_val } else { pixel[2] },
pixel[3],
]);
img.put_pixel(x, y, new_pixel);
}
channel_idx += 1;
}
}
}
fn extract_seed_lsb_fallback(img: &RgbaImage) -> Option<u64> {
let (width, height) = img.dimensions();
let total_channels = (width * height * 3) as usize;
if total_channels < 64 {
return None;
}
let mut bytes = [0u8; 8];
let mut channel_idx = 0;
for byte in bytes.iter_mut() {
for bit in 0..8 {
let pixel_offset = channel_idx / 3;
let channel = channel_idx % 3;
let x = pixel_offset as u32 % width;
let y = pixel_offset as u32 / width;
let pixel = img.get_pixel(x, y);
*byte |= (pixel[channel] & 1) << bit;
channel_idx += 1;
}
}
let seed = u64::from_le_bytes(bytes);
if seed == 0 {
None
} else {
Some(seed)
}
}
fn apply_to_image_owned(
&self,
img: &DynamicImage,
ctx: &ProtectionContext,
) -> Result<DynamicImage> {
let payload = self.generate_payload(ctx);
let rgba = img.to_rgba8();
let format = ctx
.input_format()
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT);
let redundancy = ctx.effective_redundancy();
let processed = match format {
crate::types::ImageOutputFormat::Png => {
if let Some(tile_size) = ctx.tile_size().filter(|&s| s > 0) {
self.embed_lsb_tiled(&rgba, &payload, ctx.seed(), tile_size)
} else {
self.embed_lsb(&rgba, &payload, ctx.seed(), redundancy)
}
}
crate::types::ImageOutputFormat::Jpeg => {
let jpeg_bytes = crate::util::image::encode_image_with_options(
img,
Some(crate::types::ImageOutputFormat::Jpeg),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
let with_stego = self.apply_dct_stego_bytes(&jpeg_bytes, ctx)?;
return Ok(image::load_from_memory(&with_stego)?);
}
crate::types::ImageOutputFormat::WebP => {
if let Some(tile_size) = ctx.tile_size().filter(|&s| s > 0) {
self.embed_lsb_tiled(&rgba, &payload, ctx.seed(), tile_size)
} else {
self.embed_lsb(&rgba, &payload, ctx.seed(), redundancy)
}
}
};
let mut result = processed;
Self::embed_seed_lsb_fallback(&mut result, ctx.seed());
Ok(DynamicImage::ImageRgba8(result))
}
}
impl Default for SteganographyProtector {
fn default() -> Self {
Self::new()
}
}
impl Protector for SteganographyProtector {
fn apply<'a>(
&self,
img: &'a DynamicImage,
ctx: &ProtectionContext,
) -> Result<Cow<'a, DynamicImage>> {
Ok(Cow::Owned(self.apply_to_image_owned(img, ctx)?))
}
fn apply_bytes(&self, img_bytes: &[u8], ctx: &ProtectionContext) -> Result<Vec<u8>> {
let format = ctx.input_format().unwrap_or_else(|| {
crate::types::ImageOutputFormat::from_magic_bytes(img_bytes)
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT)
});
if format == crate::types::ImageOutputFormat::Jpeg {
return self.apply_dct_stego_bytes(img_bytes, ctx);
}
let img = image::load_from_memory(img_bytes)?;
let processed = self.apply_to_image_owned(&img, ctx)?;
let image_format = match format {
crate::types::ImageOutputFormat::Png => image::ImageFormat::Png,
crate::types::ImageOutputFormat::Jpeg => image::ImageFormat::Jpeg,
crate::types::ImageOutputFormat::WebP => image::ImageFormat::WebP,
};
crate::util::image::encode_image(&processed, image_format)
}
fn name(&self) -> &'static str {
"steganography"
}
fn protection_level(&self) -> ProtectionLevel {
ProtectionLevel::Standard
}
fn estimated_latency_ms(&self) -> u32 {
2
}
}
#[derive(Debug, Clone)]
pub struct StegoPayload {
protection_level: u8,
seed: u64,
intensity: f32,
version: u8,
content_hash: Option<[u8; 4]>,
dmi_value: Option<crate::types::DmiValue>,
raw_payload: Option<Vec<u8>>,
}
impl StegoPayload {
#[must_use]
pub fn protection_level(&self) -> u8 {
self.protection_level
}
#[must_use]
pub fn seed(&self) -> u64 {
self.seed
}
#[must_use]
pub fn intensity(&self) -> f32 {
self.intensity
}
#[must_use]
pub fn version(&self) -> u8 {
self.version
}
#[must_use]
pub fn content_hash(&self) -> Option<[u8; 4]> {
self.content_hash
}
#[must_use]
pub fn dmi_value(&self) -> Option<crate::types::DmiValue> {
self.dmi_value
}
#[must_use]
pub fn raw_payload(&self) -> Option<&[u8]> {
self.raw_payload.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ProtectionConfig;
use image::ImageEncoder;
use image::{ImageBuffer, RgbaImage};
use std::sync::Arc;
fn make_test_image(w: u32, h: u32) -> RgbaImage {
ImageBuffer::from_fn(w, h, |x, y| {
Rgba([(x * 3) as u8, (y * 5) as u8, ((x + y) * 7) as u8, 255])
})
}
fn make_large_test_image() -> RgbaImage {
make_test_image(128, 128)
}
fn make_high_entropy_test_image(w: u32, h: u32) -> RgbaImage {
ImageBuffer::from_fn(w, h, |x, y| {
let r = ((x * 73 + y * 151 + (x ^ y) * 17 + x * y) % 256) as u8;
let g = ((x * 53 + y * 97 + (x * 3 + y * 5)) % 256) as u8;
let b = ((x * 29 + y * 43 + (x ^ (y << 1)) * 11 + x * y * 3) % 256) as u8;
Rgba([r, g, b, 255])
})
}
fn image_to_jpeg_bytes(img: &DynamicImage, quality: u8) -> Vec<u8> {
let mut buffer = Vec::new();
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buffer, quality);
encoder
.write_image(
&img.to_rgb8(),
img.width(),
img.height(),
image::ExtendedColorType::Rgb8,
)
.unwrap();
buffer
}
fn ctx_no_mac(seed: u64) -> ProtectionContext {
ProtectionContext::new(0.5, seed)
}
fn ctx_with_mac(seed: u64, key: &[u8]) -> ProtectionContext {
let config = Arc::new(ProtectionConfig::new().with_mac_key(key.to_vec()));
ProtectionContext::new(0.5, seed).with_config(config)
}
#[test]
fn bytes_to_bits_length() {
let data = [0xAA, 0x55, 0xFF, 0x00];
let bits = SteganographyProtector::bytes_to_bits(&data);
assert_eq!(bits.len(), 32);
}
#[test]
fn bits_to_bytes_roundtrip() {
let original: Vec<u8> = vec![0x00, 0xFF, 0xA5, 0x5A, 0x01, 0x80, 0xFE, 0x7F];
let bits = SteganographyProtector::bytes_to_bits(&original);
let recovered = SteganographyProtector::bits_to_bytes(&bits);
assert_eq!(original, recovered);
}
#[test]
fn bytes_to_bits_lsb_order() {
let data = [0b0000_0001];
let bits = SteganographyProtector::bytes_to_bits(&data);
assert_eq!(bits[0], 1);
assert_eq!(bits[1], 0);
assert_eq!(bits[7], 0);
}
#[test]
fn bytes_to_bits_high_bit() {
let data = [0b1000_0000];
let bits = SteganographyProtector::bytes_to_bits(&data);
assert_eq!(bits[7], 1);
assert_eq!(bits[0], 0);
}
#[test]
fn bits_to_bytes_trailing_dropped() {
let bits = vec![1, 0, 0, 0, 0, 0, 0, 0];
let bytes = SteganographyProtector::bits_to_bytes(&bits);
assert_eq!(bytes.len(), 1);
assert_eq!(bytes[0], 1);
}
#[test]
fn bits_to_bytes_non_multiple_of_8_returns_empty() {
let bits = vec![1, 0, 1]; let bytes = SteganographyProtector::bits_to_bytes(&bits);
assert!(
bytes.is_empty(),
"Non-multiple-of-8 input should return empty Vec"
);
}
#[test]
fn bits_to_bytes_empty_input() {
let bits: Vec<u8> = vec![];
let bytes = SteganographyProtector::bits_to_bytes(&bits);
assert!(bytes.is_empty());
}
#[test]
fn bits_to_bytes_16_bits() {
let bits = vec![1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];
let bytes = SteganographyProtector::bits_to_bytes(&bits);
assert_eq!(bytes.len(), 2);
assert_eq!(bytes[0], 1);
assert_eq!(bytes[1], 2);
}
#[test]
fn compute_checksum_deterministic() {
let data = vec![1u8; 24];
let a = SteganographyProtector::compute_checksum(&data);
let b = SteganographyProtector::compute_checksum(&data);
assert_eq!(a, b);
}
#[test]
fn compute_checksum_different_data_different_result() {
let a = SteganographyProtector::compute_checksum(&[0u8; 24]);
let b = SteganographyProtector::compute_checksum(&[1u8; 24]);
assert_ne!(a, b);
}
#[test]
fn verify_checksum_valid() {
let mut payload = vec![0u8; 24];
payload[0] = 1;
payload[1] = 2;
let checksum = SteganographyProtector::compute_checksum(&payload);
payload.extend_from_slice(&checksum);
assert!(SteganographyProtector::verify_checksum(&payload));
}
#[test]
fn verify_checksum_invalid() {
let mut payload = vec![0u8; 28];
payload[24] = 0xFF;
payload[25] = 0xFF;
payload[26] = 0xFF;
payload[27] = 0xFF;
assert!(!SteganographyProtector::verify_checksum(&payload));
}
#[test]
fn verify_checksum_too_short() {
assert!(!SteganographyProtector::verify_checksum(&[0u8; 10]));
}
#[test]
fn verify_checksum_corrupted_byte() {
let mut payload = vec![1u8; 24];
let checksum = SteganographyProtector::compute_checksum(&payload);
payload.extend_from_slice(&checksum);
assert!(SteganographyProtector::verify_checksum(&payload));
payload[5] = payload[5].wrapping_add(1);
let new_checksum = SteganographyProtector::compute_checksum(&payload[..24]);
assert_ne!(
[payload[24], payload[25], payload[26], payload[27]],
new_checksum
);
}
#[test]
fn compute_payload_mac_deterministic() {
let data = vec![1u8; 24];
let key = b"test-secret-key";
let a = SteganographyProtector::compute_payload_mac(&data, key);
let b = SteganographyProtector::compute_payload_mac(&data, key);
assert_eq!(a, b);
}
#[test]
fn compute_payload_mac_different_keys() {
let data = vec![1u8; 24];
let a = SteganographyProtector::compute_payload_mac(&data, b"key-a");
let b = SteganographyProtector::compute_payload_mac(&data, b"key-b");
assert_ne!(a, b);
}
#[test]
fn compute_payload_mac_different_data() {
let key = b"test-key";
let a = SteganographyProtector::compute_payload_mac(&[0u8; 24], key);
let b = SteganographyProtector::compute_payload_mac(&[1u8; 24], key);
assert_ne!(a, b);
}
#[test]
fn verify_payload_mac_match() {
let data = vec![42u8; 24];
let key = b"my-key";
let mac = SteganographyProtector::compute_payload_mac(&data, key);
assert!(SteganographyProtector::verify_payload_mac(&data, key, &mac));
}
#[test]
fn verify_payload_mac_wrong_key() {
let data = vec![42u8; 24];
let mac = SteganographyProtector::compute_payload_mac(&data, b"correct-key");
assert!(!SteganographyProtector::verify_payload_mac(
&data,
b"wrong-key",
&mac
));
}
#[test]
fn verify_payload_mac_corrupted_mac() {
let data = vec![42u8; 24];
let key = b"key";
let mut mac = SteganographyProtector::compute_payload_mac(&data, key);
mac[0] ^= 0xFF;
assert!(!SteganographyProtector::verify_payload_mac(
&data, key, &mac
));
}
#[test]
fn verify_payload_integrity_checksum_mode() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let payload = protector.generate_payload(&ctx);
assert_eq!(payload.len(), V3_CRC_PAYLOAD_SIZE);
assert!(SteganographyProtector::verify_payload_integrity(
&payload,
&[]
));
}
#[test]
fn verify_payload_integrity_mac_mode() {
let protector = SteganographyProtector::new();
let ctx = ctx_with_mac(42, b"secret");
let payload = protector.generate_payload(&ctx);
assert_eq!(payload.len(), V3_HMAC_PAYLOAD_SIZE);
assert!(SteganographyProtector::verify_payload_integrity(
&payload, b"secret"
));
}
#[test]
fn verify_payload_integrity_mac_wrong_key() {
let protector = SteganographyProtector::new();
let ctx = ctx_with_mac(42, b"correct");
let payload = protector.generate_payload(&ctx);
assert!(!SteganographyProtector::verify_payload_integrity(
&payload, b"wrong"
));
}
#[test]
fn verify_payload_integrity_checksum_corrupted() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let mut payload = protector.generate_payload(&ctx);
payload[5] ^= 0xFF;
assert!(!SteganographyProtector::verify_payload_integrity(
&payload,
&[]
));
}
#[test]
fn verify_payload_integrity_truncated() {
assert!(!SteganographyProtector::verify_payload_integrity(
&[0u8; 10],
&[]
));
}
#[test]
fn verify_payload_integrity_mac_truncated() {
assert!(!SteganographyProtector::verify_payload_integrity(
&[0u8; 30], b"key"
));
}
#[test]
fn generate_payload_checksum_mode_length() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(12345);
let payload = protector.generate_payload(&ctx);
assert_eq!(payload.len(), V3_CRC_PAYLOAD_SIZE);
}
#[test]
fn generate_payload_mac_mode_length() {
let protector = SteganographyProtector::new();
let ctx = ctx_with_mac(12345, b"key");
let payload = protector.generate_payload(&ctx);
assert_eq!(payload.len(), V3_HMAC_PAYLOAD_SIZE);
}
#[test]
fn generate_payload_version_byte() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let payload = protector.generate_payload(&ctx);
assert_eq!(&payload[0..2], &V3_MAGIC);
assert_eq!(payload[2], 3);
}
#[test]
fn generate_payload_seed_roundtrip() {
let seed = 0xDEAD_BEEF_CAFE_BABE;
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(seed);
let payload = protector.generate_payload(&ctx);
let extracted_seed = u64::from_le_bytes([
payload[11],
payload[12],
payload[13],
payload[14],
payload[15],
payload[16],
payload[17],
payload[18],
]);
assert_eq!(extracted_seed, seed);
}
#[test]
fn generate_payload_intensity_precision() {
let protector = SteganographyProtector::new();
let ctx = ProtectionContext::new(0.73, 42);
let payload = protector.generate_payload(&ctx);
let intensity_raw = u16::from_le_bytes([payload[19], payload[20]]);
let recovered = intensity_raw as f32 / 100.0;
assert!((recovered - 0.73).abs() < 0.02);
}
#[test]
fn generate_payload_protection_level_byte() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let payload = protector.generate_payload(&ctx);
let channels = u16::from_le_bytes([payload[8], payload[9]]);
assert_ne!(
channels, 0,
"channels should be non-zero for Standard level"
);
}
#[test]
fn generate_payload_different_seeds_differ() {
let protector = SteganographyProtector::new();
let a = protector.generate_payload(&ctx_no_mac(1));
let b = protector.generate_payload(&ctx_no_mac(2));
assert_ne!(a[11..19], b[11..19]); }
#[test]
fn stego_permutation_deterministic() {
let a = SteganographyProtector::stego_permutation(0, 1024, 42);
let b = SteganographyProtector::stego_permutation(0, 1024, 42);
assert_eq!(a, b);
}
#[test]
fn stego_permutation_different_seeds_differ() {
let a = SteganographyProtector::stego_permutation(0, 1024, 42);
let b = SteganographyProtector::stego_permutation(0, 1024, 99);
assert_ne!(a, b);
}
#[test]
fn stego_permutation_power_of_2_injective() {
let total = 1024usize;
let seed = 42u64;
let mut seen = vec![false; total];
for i in 0..total {
let pos = SteganographyProtector::stego_permutation(i, total, seed);
assert!(
pos < total,
"permutation out of range: {} >= {}",
pos,
total
);
assert!(!seen[pos], "collision at index {} -> pos {}", i, pos);
seen[pos] = true;
}
}
#[test]
fn stego_permutation_index0_consistent() {
let a = SteganographyProtector::stego_permutation(0, 4096, 100);
let b = SteganographyProtector::stego_permutation(0, 4096, 100);
assert_eq!(a, b);
}
#[test]
fn embed_bit_in_pixel_modifies_correct_channel() {
let mut img = make_test_image(4, 4);
let orig_g = img.get_pixel(0, 0)[1];
let orig_b = img.get_pixel(0, 0)[2];
let orig_a = img.get_pixel(0, 0)[3];
SteganographyProtector::embed_bit_in_pixel(&mut img, 0, 0, 0, 1);
let modified = img.get_pixel(0, 0);
assert_eq!(modified[0] & 1, 1);
assert_eq!(modified[1], orig_g);
assert_eq!(modified[2], orig_b);
assert_eq!(modified[3], orig_a);
}
#[test]
fn embed_bit_in_pixel_clears_lsb() {
let mut img = ImageBuffer::from_pixel(1, 1, Rgba([0xFF, 0xFF, 0xFF, 255]));
SteganographyProtector::embed_bit_in_pixel(&mut img, 0, 0, 1, 0);
let pixel = img.get_pixel(0, 0);
assert_eq!(pixel[1] & 1, 0);
assert_eq!(pixel[0], 0xFF);
assert_eq!(pixel[2], 0xFF);
}
#[test]
fn lsb_embed_extract_png() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector.verify_payload(&result));
let payload = protector.extract_payload(&result).unwrap();
assert_eq!(payload.seed(), 42);
}
#[test]
fn lsb_embed_extract_different_seeds() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx_a = ctx_no_mac(42);
let ctx_b = ctx_no_mac(99);
let dyn_img = DynamicImage::ImageRgba8(img.clone());
let result_a = protector.apply(&dyn_img, &ctx_a).unwrap();
let result_b = protector.apply(&dyn_img, &ctx_b).unwrap();
let payload_a = protector.extract_payload_with_seed(&result_a, 42).unwrap();
let payload_b = protector.extract_payload_with_seed(&result_b, 99).unwrap();
assert_eq!(payload_a.seed(), 42);
assert_eq!(payload_b.seed(), 99);
}
#[test]
fn lsb_embed_extract_high_redundancy() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ProtectionContext::new(0.5, 42).with_stego_redundancy(5);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector.verify_payload(&result));
}
#[test]
fn lsb_embed_modifies_pixels() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img.clone());
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert_ne!(*result, DynamicImage::ImageRgba8(img));
}
#[test]
fn lsb_preserves_dimensions() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let (w, h) = img.dimensions();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert_eq!(result.width(), w);
assert_eq!(result.height(), h);
}
#[test]
fn lsb_verify_with_mac_key() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let key = b"test-mac-key";
let ctx = ctx_with_mac(42, key);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let payload = protector.extract_payload_with_seed_and_key(&result, 42, key);
assert!(payload.is_some());
assert_eq!(payload.unwrap().seed(), 42);
assert!(protector
.extract_payload_with_seed_and_key(&result, 42, b"wrong-key")
.is_none());
}
#[test]
fn lsb_extract_wrong_key_returns_none() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_with_mac(42, b"correct");
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector
.extract_payload_with_key(&result, b"correct")
.is_some());
assert!(protector
.extract_payload_with_key(&result, b"wrong")
.is_none());
}
#[test]
fn lsb_payload_too_large_returns_unchanged() {
let protector = SteganographyProtector::new();
let tiny = make_test_image(2, 2); let ctx = ctx_no_mac(42);
let payload = protector.generate_payload(&ctx);
let result = protector.embed_lsb(&tiny, &payload, 42, 1);
assert_eq!(result, tiny);
}
#[test]
fn lsb_extract_oversized_expected_bits_returns_none() {
let protector = SteganographyProtector::new();
let img = make_test_image(4, 4); assert!(protector.extract_lsb(&img, 256, 42).is_none());
}
#[test]
fn dct_stego_low_capacity_keeps_qtable_seed_only() {
let protector = SteganographyProtector::new();
let img = make_test_image(16, 16);
let jpeg_bytes = image_to_jpeg_bytes(&DynamicImage::ImageRgba8(img), 90);
let ctx = ProtectionContext::new(0.5, 42)
.with_format(crate::types::ImageOutputFormat::Jpeg)
.with_stego_redundancy(3);
let payload_bits = protector.generate_payload(&ctx).len() * 8;
let required_bits = payload_bits * ctx.effective_redundancy();
let (_, coefficients) = JpegTranscoder::decode_coefficients(&jpeg_bytes).unwrap();
assert!(SteganographyProtector::dct_payload_capacity(&coefficients) < required_bits);
let protected = protector.apply_dct_stego_bytes(&jpeg_bytes, &ctx).unwrap();
let (header, _) = JpegTranscoder::decode_coefficients(&protected).unwrap();
assert_eq!(
DctStegoF5::new().extract_seed_from_quantization_tables(&header),
Some(42),
"JPEG output should still carry the Q-table seed"
);
assert!(
!protector.verify_payload_from_bytes(&protected, 42),
"Q-table seed alone must not count as full verification"
);
}
#[test]
fn dct_stego_high_capacity_verifies_with_redundancy_3() {
let protector = SteganographyProtector::new();
let img = make_high_entropy_test_image(1024, 1024);
let jpeg_bytes = image_to_jpeg_bytes(&DynamicImage::ImageRgba8(img), 90);
let ctx = ProtectionContext::new(0.5, 42)
.with_format(crate::types::ImageOutputFormat::Jpeg)
.with_stego_redundancy(3);
let payload_bits = protector.generate_payload(&ctx).len() * 8;
let (_, coefficients) = JpegTranscoder::decode_coefficients(&jpeg_bytes).unwrap();
assert!(SteganographyProtector::dct_payload_capacity(&coefficients) >= payload_bits * 3);
let protected = protector.apply_dct_stego_bytes(&jpeg_bytes, &ctx).unwrap();
let (header, _) = JpegTranscoder::decode_coefficients(&protected).unwrap();
assert_eq!(
DctStegoF5::new().extract_seed_from_quantization_tables(&header),
Some(42)
);
assert!(
protector.verify_payload_from_bytes(&protected, 42),
"DCT payload should verify with redundancy=3 when capacity permits"
);
}
#[test]
fn parse_stego_payload_valid() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(12345);
let payload = protector.generate_payload(&ctx);
let parsed = SteganographyProtector::parse_stego_payload(&payload).unwrap();
assert_eq!(parsed.version(), 3);
assert_eq!(parsed.seed(), 12345);
assert!((parsed.intensity() - 0.5).abs() < 0.02);
}
#[test]
fn parse_stego_payload_too_short() {
assert!(SteganographyProtector::parse_stego_payload(&[0u8; 10]).is_none());
}
#[test]
fn parse_stego_payload_wrong_version() {
let mut payload = vec![0u8; 26];
payload[0] = 99; assert!(SteganographyProtector::parse_stego_payload(&payload).is_none());
}
#[test]
fn current_payload_version_is_in_supported_list() {
assert!(
SUPPORTED_PAYLOAD_VERSIONS.contains(&V3_PAYLOAD_VERSION),
"SUPPORTED_PAYLOAD_VERSIONS must include V3_PAYLOAD_VERSION ({}) \
so freshly-generated payloads are always parseable",
V3_PAYLOAD_VERSION
);
}
#[test]
fn supported_versions_are_unique() {
let mut sorted: Vec<u8> = SUPPORTED_PAYLOAD_VERSIONS.to_vec();
sorted.dedup();
assert_eq!(
sorted.len(),
SUPPORTED_PAYLOAD_VERSIONS.len(),
"SUPPORTED_PAYLOAD_VERSIONS must not contain duplicate versions"
);
}
#[test]
fn migration_path_v1_still_parses_after_v2_introduced() {
let mut v1_payload = vec![0u8; 24];
v1_payload[0] = 1; v1_payload[1] = 2; v1_payload[2..10].copy_from_slice(&0xDEADBEEFu64.to_le_bytes());
v1_payload[10..12].copy_from_slice(&50u16.to_le_bytes());
let parsed = SteganographyProtector::parse_stego_payload(&v1_payload).unwrap();
assert_eq!(parsed.version(), 1);
assert_eq!(parsed.seed(), 0xDEADBEEF);
assert_eq!(parsed.protection_level(), 2);
}
#[test]
fn extract_with_redundancy_finds_payload() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector.verify_payload(&result));
}
#[test]
fn extract_with_redundancy_mac_mode() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let key = b"hmac-key";
let ctx = ctx_with_mac(42, key);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let payload = protector.extract_payload_with_seed_and_key(&result, 42, key);
assert!(payload.is_some());
assert_eq!(payload.unwrap().seed(), 42);
}
#[test]
fn extract_with_redundancy_mac_wrong_key_returns_none() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_with_mac(42, b"correct-key");
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector
.extract_payload_with_key(&result, b"wrong-key")
.is_none());
}
#[test]
fn protector_apply_changes_image() {
let p = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img.clone());
let result = p.apply(&dyn_img, &ctx).unwrap();
match result {
std::borrow::Cow::Owned(owned) => {
assert_ne!(owned.to_rgba8(), img);
}
_ => panic!("expected owned result"),
}
}
#[test]
fn protector_apply_preserves_dimensions() {
let p = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let (w, h) = img.dimensions();
let dyn_img = DynamicImage::ImageRgba8(img);
let result = p.apply(&dyn_img, &ctx).unwrap();
assert_eq!(result.width(), w);
assert_eq!(result.height(), h);
}
#[test]
fn protector_level() {
let p = SteganographyProtector::new();
assert_eq!(p.protection_level(), ProtectionLevel::Standard);
}
#[test]
fn protector_modifies_pixels() {
let p = SteganographyProtector::new();
assert!(p.modifies_pixels());
}
#[test]
fn protector_apply_bytes_png_roundtrip() {
let p = SteganographyProtector::new();
let img = make_large_test_image();
let png_bytes = crate::util::image::encode_image(
&DynamicImage::ImageRgba8(img),
image::ImageFormat::Png,
)
.unwrap();
let ctx = ctx_no_mac(42);
let processed = p.apply_bytes(&png_bytes, &ctx).unwrap();
let decoded = image::load_from_memory(&processed).unwrap();
assert!(p.verify_payload(&decoded));
}
#[test]
fn dct_stego_rejects_non_jpeg() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let result = protector.apply_dct_stego_bytes(&[0x89, 0x50, 0x4E, 0x47], &ctx);
assert!(result.is_err());
}
#[test]
fn dct_stego_rejects_empty() {
let protector = SteganographyProtector::new();
let ctx = ctx_no_mac(42);
let result = protector.apply_dct_stego_bytes(&[], &ctx);
assert!(result.is_err());
}
#[test]
fn extract_seed_from_protected_image() {
let meta = MetadataTrapProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let png_bytes = crate::util::image::encode_image(
&DynamicImage::ImageRgba8(img),
image::ImageFormat::Png,
)
.unwrap();
let with_metadata = meta.apply_bytes(&png_bytes, &ctx).unwrap();
let extracted = MetadataTrapProtector::extract_seed_from_image(&with_metadata);
assert_eq!(extracted, Some(42));
}
#[test]
fn full_roundtrip_mac_embed_extract_verify() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let key = b"super-secret";
let ctx = ctx_with_mac(42, key);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let payload = protector.extract_payload_with_key(&result, key);
assert!(payload.is_some());
let p = payload.unwrap();
assert_eq!(p.seed(), 42);
assert_eq!(p.version(), 3);
}
#[test]
fn full_roundtrip_no_mac_embed_extract_verify() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let payload = protector.extract_payload(&result);
assert!(payload.is_some());
let p = payload.unwrap();
assert_eq!(p.seed(), 42);
}
#[test]
fn full_roundtrip_verify_payload() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector.verify_payload(&result));
}
#[test]
fn jpeg_stego_redundancy_extraction_succeeds() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(42);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let payload = protector.extract_payload(&result);
assert!(
payload.is_some(),
"Should extract payload after embedding with redundancy"
);
let p = payload.unwrap();
assert_eq!(p.seed(), 42);
}
#[test]
fn jpeg_stego_redundancy_multiple_extraction_seeds_work() {
let protector = SteganographyProtector::new();
let img = make_large_test_image();
let ctx = ctx_no_mac(99999);
let dyn_img = DynamicImage::ImageRgba8(img);
let result = protector.apply(&dyn_img, &ctx).unwrap();
let extracted_0 = protector.extract_payload(&result);
let extracted_1 = protector.extract_payload(&result);
let extracted_2 = protector.extract_payload(&result);
assert!(extracted_0.is_some(), "Extraction should succeed");
assert!(extracted_1.is_some(), "Extraction should succeed");
assert!(extracted_2.is_some(), "Extraction should succeed");
assert_eq!(
extracted_0.clone().unwrap().seed(),
extracted_1.clone().unwrap().seed(),
"All extractions should produce identical seeds"
);
assert_eq!(
extracted_0.unwrap().seed(),
extracted_2.unwrap().seed(),
"All extractions should produce identical seeds"
);
}
#[test]
fn tile_seed_is_deterministic() {
let a = tile_seed(42, 3, 7);
let b = tile_seed(42, 3, 7);
assert_eq!(a, b);
}
#[test]
fn tile_seed_distinct_for_distinct_x() {
let a = tile_seed(42, 0, 0);
let b = tile_seed(42, 1, 0);
assert_ne!(a, b);
}
#[test]
fn tile_seed_distinct_for_distinct_y() {
let a = tile_seed(42, 0, 0);
let b = tile_seed(42, 0, 1);
assert_ne!(a, b);
}
#[test]
fn tile_seed_distinct_for_distinct_master() {
let a = tile_seed(42, 1, 1);
let b = tile_seed(99, 1, 1);
assert_ne!(a, b);
}
#[test]
fn tile_seed_collisions_rare() {
let mut seen = std::collections::HashSet::new();
for x in 0..8 {
for y in 0..8 {
seen.insert(tile_seed(0xDEAD_BEEF, x, y));
}
}
assert_eq!(seen.len(), 64);
}
fn tileable_test_image() -> RgbaImage {
make_high_entropy_test_image(128, 128)
}
fn real_payload(seed: u64) -> Vec<u8> {
let ctx = ctx_no_mac(seed);
SteganographyProtector::new().generate_payload(&ctx)
}
#[test]
fn embed_lsb_tiled_no_crop_round_trip() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
assert_eq!(embedded.dimensions(), img.dimensions());
let recovered = protector
.extract_lsb_tiled_candidates(&embedded, 42, 64, 64, &[])
.expect("tiled extraction should recover payload from un-cropped image");
assert_eq!(recovered, payload);
}
#[test]
fn embed_lsb_tiled_survives_aligned_crop() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let cropped = SteganographyProtector::crop_rgba(&embedded, 64, 0, 64, 64);
let recovered = protector
.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, &[])
.expect("tiled extraction should recover payload from aligned crop");
assert_eq!(recovered, payload);
}
#[test]
fn embed_lsb_tiled_survives_misaligned_crop() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let cropped = SteganographyProtector::crop_rgba(&embedded, 32, 32, 96, 96);
let recovered = protector
.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, &[])
.expect("tiled extraction should recover payload from misaligned crop");
assert_eq!(&recovered[..payload.len()], &payload[..]);
}
#[test]
fn embed_lsb_tiled_survives_crop_smaller_than_image() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let cropped = SteganographyProtector::crop_rgba(&embedded, 0, 0, 96, 128);
let recovered = protector
.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, &[])
.expect("tiled extraction should recover payload from partial-image crop");
assert_eq!(recovered, payload);
}
#[test]
fn embed_lsb_tiled_with_mac_key() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let ctx = ctx_with_mac(42, b"my-key");
let payload = protector.generate_payload(&ctx);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let cropped = SteganographyProtector::crop_rgba(&embedded, 32, 32, 96, 96);
let recovered = protector
.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, b"my-key")
.expect("tiled extraction with correct MAC should recover payload");
assert_eq!(&recovered[..payload.len()], &payload[..]);
assert!(protector
.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, b"wrong-key")
.is_none());
}
#[test]
fn embed_lsb_tiled_max_origins_limits_scan() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let recovered = protector
.extract_lsb_tiled_candidates(&embedded, 42, 64, 1, &[])
.expect("max_origins=1 should still find payload at (0, 0) origin");
assert_eq!(recovered, payload);
}
#[test]
fn embed_lsb_tiled_zero_tile_size_falls_back() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let result = protector.embed_lsb_tiled(&img, &payload, 42, 0);
assert_eq!(result, img);
}
#[test]
fn embed_lsb_tiled_does_not_affect_non_cropped_extraction() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let dyn_img = DynamicImage::ImageRgba8(img);
let protected = protector.apply(&dyn_img, &ctx).unwrap();
assert!(protector.verify_payload(&protected));
}
#[test]
fn embed_lsb_tiled_extract_via_public_api_after_crop() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let dyn_img = DynamicImage::ImageRgba8(img);
let mut protected = protector.apply(&dyn_img, &ctx).unwrap().into_owned();
let cropped = protected.crop(64, 0, 64, 64);
let extracted = protector.extract_payload_with_seed(&cropped, 42);
assert!(
extracted.is_some(),
"extract_payload_with_seed should recover tiled payload from cropped image via the verify-chain fallback"
);
assert_eq!(extracted.unwrap().seed(), 42);
}
#[test]
fn embed_lsb_tiled_survives_4_pixel_alignment_shift() {
let protector = SteganographyProtector::new();
let img = tileable_test_image();
let payload = real_payload(42);
let embedded = protector.embed_lsb_tiled(&img, &payload, 42, 64);
let cropped = SteganographyProtector::crop_rgba(&embedded, 4, 4, 124, 124);
let recovered = protector.extract_lsb_tiled_candidates(&cropped, 42, 64, 64, &[]);
assert!(
recovered.is_none(),
"LSB tiled stego does NOT survive sub-tile (4px) misaligned crop — extractor grid search is stride-based"
);
}
#[test]
fn embed_f5_tiled_round_trip_after_recompression() {
let protector = SteganographyProtector::new();
let jpeg_bytes = tileable_test_jpeg();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let protected = protector
.apply_dct_stego_bytes_tiled(&jpeg_bytes, &ctx, 64)
.unwrap();
let img = image::load_from_memory(&protected).unwrap();
let reencoded = image_to_jpeg_bytes(&img, 85);
let recovered = protector.extract_f5_tiled_candidates(&reencoded, 42, 64, 64, &[]);
let _ = recovered;
}
#[test]
fn embed_f5_tiled_round_trip_no_crop() {
let protector = SteganographyProtector::new();
let jpeg_bytes = tileable_test_jpeg();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let protected = protector
.apply_dct_stego_bytes_tiled(&jpeg_bytes, &ctx, 64)
.unwrap();
let recovered = protector.extract_f5_tiled_candidates(&protected, 42, 64, 64, &[]);
assert!(
recovered.is_some(),
"F5 tiled extraction should recover payload from un-cropped JPEG"
);
}
#[test]
fn embed_f5_tiled_survives_aligned_crop() {
let protector = SteganographyProtector::new();
let jpeg_bytes = tileable_test_jpeg();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let protected = protector
.apply_dct_stego_bytes_tiled(&jpeg_bytes, &ctx, 64)
.unwrap();
let img = image::load_from_memory(&protected).unwrap();
let rgba = img.to_rgba8();
let cropped_rgba = SteganographyProtector::crop_rgba(&rgba, 0, 0, 64, 64);
let cropped_img = DynamicImage::ImageRgba8(cropped_rgba);
let mut buf = std::io::Cursor::new(Vec::new());
cropped_img
.write_to(&mut buf, image::ImageFormat::Jpeg)
.unwrap();
let cropped_jpeg = buf.into_inner();
let recovered = protector.extract_f5_tiled_candidates(&cropped_jpeg, 42, 64, 64, &[]);
let _ = recovered;
}
#[test]
fn embed_f5_tiled_with_mac_key() {
let protector = SteganographyProtector::new();
let jpeg_bytes = tileable_test_jpeg();
let ctx = ctx_with_mac(42, b"my-key").with_tile_size(64);
let protected = protector
.apply_dct_stego_bytes_tiled(&jpeg_bytes, &ctx, 64)
.unwrap();
let recovered = protector.extract_f5_tiled_candidates(&protected, 42, 64, 64, b"my-key");
assert!(
recovered.is_some(),
"F5 tiled extraction with MAC key should recover payload"
);
let wrong = protector.extract_f5_tiled_candidates(&protected, 42, 64, 64, b"wrong-key");
assert!(
wrong.is_none(),
"F5 tiled extraction with wrong MAC key should fail"
);
}
#[test]
fn embed_f5_tiled_max_origins_limits_scan() {
let protector = SteganographyProtector::new();
let jpeg_bytes = tileable_test_jpeg();
let ctx = ProtectionContext::new(0.5, 42).with_tile_size(64);
let protected = protector
.apply_dct_stego_bytes_tiled(&jpeg_bytes, &ctx, 64)
.unwrap();
let recovered = protector.extract_f5_tiled_candidates(&protected, 42, 64, 1, &[]);
assert!(
recovered.is_some(),
"max_origins=1 should still find payload at first tile"
);
}
#[test]
fn wrong_mac_key_returns_none() {
use crate::ImageOutputFormat;
let protector = SteganographyProtector::new();
let img = DynamicImage::ImageRgba8(make_test_image(64, 64));
let correct_key = b"correct-secret-key";
let wrong_key = b"wrong-secret-key!!";
let ctx = ProtectionContext::new(0.5, 42)
.with_mac_key(correct_key.to_vec())
.with_format(ImageOutputFormat::Png);
let protected = protector.apply(&img, &ctx).unwrap();
let payload_correct = protector.extract_payload_with_key(&protected, correct_key);
assert!(
payload_correct.is_some(),
"Should extract payload with correct key"
);
let payload_wrong = protector.extract_payload_with_key(&protected, wrong_key);
assert!(
payload_wrong.is_none(),
"extract_payload_with_key should return None with wrong MAC key"
);
}
fn tileable_test_jpeg() -> Vec<u8> {
let img = tileable_test_image();
let dyn_img = DynamicImage::ImageRgba8(img);
let mut buf = std::io::Cursor::new(Vec::new());
dyn_img
.write_to(&mut buf, image::ImageFormat::Jpeg)
.unwrap();
buf.into_inner()
}
}