From 8e1d7f53581a117961cb02d3b480ed6ce904ca13 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 23:07:17 -0400 Subject: [PATCH] feat: add imgsecret embed/extract with DCT and majority voting Implements DCT-based steganography module that hides 256-bit secrets in JPEG luminance channel using Quantization Index Modulation (QIM) with redundant copies and majority voting for reliable extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/idfoto-core/src/imgsecret.rs | 765 ++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 2 + 2 files changed, 767 insertions(+) create mode 100644 crates/idfoto-core/src/imgsecret.rs diff --git a/crates/idfoto-core/src/imgsecret.rs b/crates/idfoto-core/src/imgsecret.rs new file mode 100644 index 0000000..4eb4d29 --- /dev/null +++ b/crates/idfoto-core/src/imgsecret.rs @@ -0,0 +1,765 @@ +//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping. +//! +//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance +//! channel using Quantization Index Modulation (QIM) with majority voting. + +use crate::error::{IdfotoError, Result}; +use image::codecs::jpeg::JpegEncoder; +use image::ImageReader; +use image::{ImageEncoder, Rgb, RgbImage}; +use std::f64::consts::PI; +use std::io::Cursor; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const BLOCK_SIZE: usize = 8; +const QUANT_STEP: f64 = 50.0; +const MIN_DIMENSION: u32 = 100; +const SECRET_BITS: usize = 256; +const MIN_COPIES: usize = 5; +const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len() +const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 + +/// Mid-frequency DCT positions (zig-zag positions 4–15) +const EMBED_POSITIONS: [(usize, usize); 12] = [ + (0, 3), + (1, 2), + (2, 1), + (3, 0), // zig-zag 4-7 + (0, 4), + (1, 3), + (2, 2), + (3, 1), // zig-zag 8-11 + (4, 0), + (0, 5), + (1, 4), + (2, 3), // zig-zag 12-15 +]; + +// ─── YChannel ──────────────────────────────────────────────────────────────── + +struct YChannel { + data: Vec, + width: usize, + height: usize, +} + +impl YChannel { + fn get(&self, x: usize, y: usize) -> f64 { + self.data[y * self.width + x] + } + + fn set(&mut self, x: usize, y: usize, val: f64) { + self.data[y * self.width + x] = val; + } +} + +// ─── EmbedRegion ───────────────────────────────────────────────────────────── + +struct EmbedRegion { + x_offset: usize, + y_offset: usize, + #[allow(dead_code)] + region_width: usize, + #[allow(dead_code)] + region_height: usize, + blocks_x: usize, + blocks_y: usize, +} + +// ─── Helper functions ──────────────────────────────────────────────────────── + +fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(jpeg_bytes)) + .with_guessed_format() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + let img = reader + .decode() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + let rgb = img.to_rgb8(); + let (width, height) = (rgb.width() as usize, rgb.height() as usize); + let mut data = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let p = rgb.get_pixel(x as u32, y as u32); + let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; + data.push(luma); + } + } + Ok(YChannel { + data, + width, + height, + }) +} + +fn central_region(y: &YChannel) -> EmbedRegion { + compute_region(y.width, y.height) +} + +fn compute_region(width: usize, height: usize) -> EmbedRegion { + let margin_x = (width as f64 * 0.15) as usize; + let margin_y = (height as f64 * 0.15) as usize; + let x_offset = margin_x; + let y_offset = margin_y; + let region_width = width - 2 * margin_x; + let region_height = height - 2 * margin_y; + let blocks_x = region_width / BLOCK_SIZE; + let blocks_y = region_height / BLOCK_SIZE; + EmbedRegion { + x_offset, + y_offset, + region_width, + region_height, + blocks_x, + blocks_y, + } +} + +fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { + if px + 8 > y.width || py + 8 > y.height { + return None; + } + let mut block = [[0.0f64; 8]; 8]; + for row in 0..8 { + for col in 0..8 { + block[row][col] = y.get(px + col, py + row); + } + } + Some(block) +} + +fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] { + let start_x = region.x_offset + bx * BLOCK_SIZE; + let start_y = region.y_offset + by * BLOCK_SIZE; + read_block_abs(y, start_x, start_y).unwrap() +} + +fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { + let start_x = region.x_offset + bx * BLOCK_SIZE; + let start_y = region.y_offset + by * BLOCK_SIZE; + for row in 0..8 { + for col in 0..8 { + y.set(start_x + col, start_y + row, block[row][col]); + } + } +} + +// ─── DCT ───────────────────────────────────────────────────────────────────── + +fn dct1d(input: &[f64; 8]) -> [f64; 8] { + let mut output = [0.0f64; 8]; + for k in 0..8 { + let ck = if k == 0 { + (1.0 / 8.0_f64).sqrt() + } else { + (2.0 / 8.0_f64).sqrt() + }; + let mut sum = 0.0; + for i in 0..8 { + sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + } + output[k] = ck * sum; + } + output +} + +fn idct1d(input: &[f64; 8]) -> [f64; 8] { + let mut output = [0.0f64; 8]; + for i in 0..8 { + let mut sum = 0.0; + for k in 0..8 { + let ck = if k == 0 { + (1.0 / 8.0_f64).sqrt() + } else { + (2.0 / 8.0_f64).sqrt() + }; + sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + } + output[i] = sum; + } + output +} + +fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { + let mut temp = [[0.0f64; 8]; 8]; + for row in 0..8 { + temp[row] = dct1d(&block[row]); + } + let mut result = [[0.0f64; 8]; 8]; + for col in 0..8 { + let mut column = [0.0f64; 8]; + for row in 0..8 { + column[row] = temp[row][col]; + } + let transformed = dct1d(&column); + for row in 0..8 { + result[row][col] = transformed[row]; + } + } + result +} + +fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { + let mut temp = [[0.0f64; 8]; 8]; + for col in 0..8 { + let mut column = [0.0f64; 8]; + for row in 0..8 { + column[row] = block[row][col]; + } + let transformed = idct1d(&column); + for row in 0..8 { + temp[row][col] = transformed[row]; + } + } + let mut result = [[0.0f64; 8]; 8]; + for row in 0..8 { + result[row] = idct1d(&temp[row]); + } + result +} + +// ─── QIM ───────────────────────────────────────────────────────────────────── + +fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { + let offset = if bit == 1 { q / 2.0 } else { 0.0 }; + let shifted = coef - offset; + let quantized = (shifted / q).round() * q; + quantized + offset +} + +fn qim_extract(coef: f64, q: f64) -> u8 { + let d0 = (coef - (coef / q).round() * q).abs(); + let offset = q / 2.0; + let shifted = coef - offset; + let d1 = (shifted - (shifted / q).round() * q).abs(); + if d0 <= d1 { 0 } else { 1 } +} + +// ─── Bit conversion ────────────────────────────────────────────────────────── + +fn bytes_to_bits(bytes: &[u8]) -> Vec { + let mut bits = Vec::with_capacity(bytes.len() * 8); + for &byte in bytes { + for i in (0..8).rev() { + bits.push((byte >> i) & 1); + } + } + bits +} + +fn bits_to_bytes(bits: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); + for chunk in bits.chunks(8) { + let mut byte = 0u8; + for (i, &bit) in chunk.iter().enumerate() { + byte |= bit << (7 - i); + } + bytes.push(byte); + } + bytes +} + +// ─── Block selection ───────────────────────────────────────────────────────── + +/// Compute the absolute pixel positions of embed blocks for a given image size. +/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks. +fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> { + let region = compute_region(img_width, img_height); + let total_blocks = region.blocks_x * region.blocks_y; + if total_blocks < BLOCKS_PER_COPY * MIN_COPIES { + return Vec::new(); + } + let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); + let target_count = num_copies * BLOCKS_PER_COPY; + + let stride = (total_blocks / target_count).max(1); + let mut positions = Vec::with_capacity(target_count); + let mut idx = 0; + while positions.len() < target_count && idx < total_blocks { + let bx = idx % region.blocks_x; + let by = idx / region.blocks_x; + let px = region.x_offset + bx * BLOCK_SIZE; + let py = region.y_offset + by * BLOCK_SIZE; + positions.push((px, py)); + idx += stride; + } + positions +} + +fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> { + let total_blocks = region.blocks_x * region.blocks_y; + if total_blocks == 0 || target_count == 0 { + return Vec::new(); + } + let stride = (total_blocks / target_count).max(1); + let mut blocks = Vec::with_capacity(target_count); + let mut idx = 0; + while blocks.len() < target_count && idx < total_blocks { + let bx = idx % region.blocks_x; + let by = idx / region.blocks_x; + blocks.push((bx, by)); + idx += stride; + } + blocks +} + +// ─── Reconstruct JPEG ──────────────────────────────────────────────────────── + +fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result> { + let reader = ImageReader::new(Cursor::new(original_jpeg)) + .with_guessed_format() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + let img = reader + .decode() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + let rgb = img.to_rgb8(); + let (width, height) = (rgb.width(), rgb.height()); + + let mut output = RgbImage::new(width, height); + + for py in 0..height { + for px in 0..width { + let orig = rgb.get_pixel(px, py); + let r = orig[0] as f64; + let g = orig[1] as f64; + let b = orig[2] as f64; + + let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b; + let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0; + let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0; + + let y_new = y_modified.get(px as usize, py as usize); + + let r_new = y_new + 1.402 * (cr - 128.0); + let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0); + let b_new = y_new + 1.772 * (cb - 128.0); + + output.put_pixel( + px, + py, + Rgb([ + r_new.round().clamp(0.0, 255.0) as u8, + g_new.round().clamp(0.0, 255.0) as u8, + b_new.round().clamp(0.0, 255.0) as u8, + ]), + ); + } + } + + let mut buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?; + Ok(buf) +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes. +pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { + let mut y = extract_y_channel(carrier_jpeg)?; + + if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { + return Err(IdfotoError::ImageTooSmall { + min_width: MIN_DIMENSION, + min_height: MIN_DIMENSION, + actual_width: y.width as u32, + actual_height: y.height as u32, + }); + } + + let region = central_region(&y); + let total_blocks = region.blocks_x * region.blocks_y; + + if total_blocks < BLOCKS_PER_COPY * MIN_COPIES { + return Err(IdfotoError::ImageTooSmall { + min_width: MIN_DIMENSION, + min_height: MIN_DIMENSION, + actual_width: y.width as u32, + actual_height: y.height as u32, + }); + } + + let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); + let bits = bytes_to_bits(secret); + + let blocks_needed = num_copies * BLOCKS_PER_COPY; + let embed_blocks = select_embed_blocks(®ion, blocks_needed); + + for copy in 0..num_copies { + for block_idx in 0..BLOCKS_PER_COPY { + let global_idx = copy * BLOCKS_PER_COPY + block_idx; + if global_idx >= embed_blocks.len() { + break; + } + let (bx, by) = embed_blocks[global_idx]; + let mut block = read_block(&y, bx, by, ®ion); + let mut dct = dct2_8x8(&block); + + for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { + let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx; + if bit_idx >= SECRET_BITS { + break; + } + dct[row][col] = qim_embed(dct[row][col], bits[bit_idx], QUANT_STEP); + } + + block = idct2_8x8(&dct); + write_block(&mut y, bx, by, ®ion, &block); + } + } + + reconstruct_jpeg(carrier_jpeg, &y) +} + +/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. +pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + extract_with_crop_recovery(jpeg_bytes) +} + +/// Try to extract using a specific assumed original image size and pixel offset. +/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies). +/// `dx`/`dy` shift all block positions when reading from the actual image. +fn try_extract_with_layout( + y: &YChannel, + orig_w: usize, + orig_h: usize, + dx: isize, + dy: isize, +) -> Result<[u8; 32]> { + let positions = compute_embed_positions(orig_w, orig_h); + if positions.is_empty() { + return Err(IdfotoError::ExtractionFailed); + } + + let region = compute_region(orig_w, orig_h); + let total_blocks = region.blocks_x * region.blocks_y; + let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); + + let mut votes_one = vec![0usize; SECRET_BITS]; + let mut votes_total = vec![0usize; SECRET_BITS]; + + for copy in 0..num_copies { + for block_idx in 0..BLOCKS_PER_COPY { + let global_idx = copy * BLOCKS_PER_COPY + block_idx; + if global_idx >= positions.len() { + break; + } + let (orig_px, orig_py) = positions[global_idx]; + let actual_px = orig_px as isize + dx; + let actual_py = orig_py as isize + dy; + if actual_px < 0 || actual_py < 0 { + continue; + } + let actual_px = actual_px as usize; + let actual_py = actual_py as usize; + + let block = match read_block_abs(y, actual_px, actual_py) { + Some(b) => b, + None => continue, // block out of bounds (cropped away) + }; + let dct = dct2_8x8(&block); + + for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { + let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx; + if bit_idx >= SECRET_BITS { + break; + } + let extracted_bit = qim_extract(dct[row][col], QUANT_STEP); + votes_total[bit_idx] += 1; + if extracted_bit == 1 { + votes_one[bit_idx] += 1; + } + } + } + } + + // Majority vote with confidence check + let mut result_bits = vec![0u8; SECRET_BITS]; + for i in 0..SECRET_BITS { + if votes_total[i] == 0 { + return Err(IdfotoError::ExtractionFailed); + } + let ones = votes_one[i]; + let zeros = votes_total[i] - ones; + let majority = ones.max(zeros); + let confidence = majority as f64 / votes_total[i] as f64; + if confidence < 0.60 { + return Err(IdfotoError::ExtractionFailed); + } + result_bits[i] = if ones > zeros { 1 } else { 0 }; + } + + let result_bytes = bits_to_bytes(&result_bits); + let mut secret = [0u8; 32]; + secret.copy_from_slice(&result_bytes[..32]); + Ok(secret) +} + +fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + let y = extract_y_channel(jpeg_bytes)?; + + if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { + return Err(IdfotoError::ExtractionFailed); + } + + // Try assuming the image is uncropped (original size = current size) + if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) { + return Ok(secret); + } + + // The image may have been cropped. Search for the original dimensions. + // A crop removes pixels from edges. The central region was computed from the + // original dimensions. We need to figure out what those were. + // + // Strategy: try original widths from current_w to current_w * 1.20, stepping + // by 8 pixels (JPEG block alignment). For each candidate original width, + // the embed grid is fully determined. We then need to find dx (the pixel + // offset due to left-side cropping, which is 0 for right-only crop). + + let max_orig_w = (y.width as f64 * 1.20) as usize; + let max_orig_h = (y.height as f64 * 1.20) as usize; + + // Try width-only crops first (most common: crop from one side) + for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) { + // Right-side crop: dx = 0 (left edge unchanged) + if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) { + return Ok(secret); + } + } + + // Try height-only crops + for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) { + if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) { + return Ok(secret); + } + } + + // Try width crops with finer step (non-8-aligned crops) + for orig_w in (y.width..=max_orig_w).step_by(1) { + if orig_w % BLOCK_SIZE == 0 { + continue; // already tried + } + if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) { + return Ok(secret); + } + } + + Err(IdfotoError::ExtractionFailed) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_jpeg(width: u32, height: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ImageBuffer, ImageEncoder, Rgb}; + let img = ImageBuffer::from_fn(width, height, |x, y| { + Rgb([ + ((x * 7 + y * 13) % 256) as u8, + ((x * 11 + y * 3) % 256) as u8, + ((x * 5 + y * 17) % 256) as u8, + ]) + }); + let mut buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + buf + } + + #[test] + fn dct2_idct2_round_trip() { + let block: [[f64; 8]; 8] = [ + [52.0, 55.0, 61.0, 66.0, 70.0, 61.0, 64.0, 73.0], + [63.0, 59.0, 55.0, 90.0, 109.0, 85.0, 69.0, 72.0], + [62.0, 59.0, 68.0, 113.0, 144.0, 104.0, 66.0, 73.0], + [63.0, 58.0, 71.0, 122.0, 154.0, 106.0, 70.0, 69.0], + [67.0, 61.0, 68.0, 104.0, 126.0, 88.0, 68.0, 70.0], + [79.0, 65.0, 60.0, 70.0, 77.0, 68.0, 58.0, 75.0], + [85.0, 71.0, 64.0, 59.0, 55.0, 61.0, 65.0, 83.0], + [87.0, 79.0, 69.0, 68.0, 65.0, 76.0, 78.0, 94.0], + ]; + + let dct = dct2_8x8(&block); + let recovered = idct2_8x8(&dct); + + for row in 0..8 { + for col in 0..8 { + assert!( + (block[row][col] - recovered[row][col]).abs() < 1e-6, + "Mismatch at ({}, {}): {} vs {}", + row, + col, + block[row][col], + recovered[row][col] + ); + } + } + } + + #[test] + fn qim_embed_extract_single_bit() { + let coefficients = [-50.0, -10.0, 0.0, 10.0, 50.0, 100.0, -100.0]; + for &coef in &coefficients { + for bit in 0..=1u8 { + let embedded = qim_embed(coef, bit, QUANT_STEP); + let extracted = qim_extract(embedded, QUANT_STEP); + assert_eq!(extracted, bit, "Failed for coef={}, bit={}", coef, bit); + } + } + } + + #[test] + fn qim_survives_small_noise() { + let coefficients = [-50.0, 0.0, 30.0, 75.0, -75.0]; + let noise_levels = [-10.0, -5.0, 5.0, 10.0]; // < QUANT_STEP/4 = 12.5 + for &coef in &coefficients { + for bit in 0..=1u8 { + let embedded = qim_embed(coef, bit, QUANT_STEP); + for &noise in &noise_levels { + let noisy = embedded + noise; + let extracted = qim_extract(noisy, QUANT_STEP); + assert_eq!( + extracted, bit, + "Failed for coef={}, bit={}, noise={}", + coef, bit, noise + ); + } + } + } + } + + #[test] + fn extract_y_channel_from_synthetic_jpeg() { + let jpeg = make_test_jpeg(200, 150); + let y = extract_y_channel(&jpeg).unwrap(); + assert_eq!(y.width, 200); + assert_eq!(y.height, 150); + assert_eq!(y.data.len(), 200 * 150); + } + + #[test] + fn read_write_block_round_trip() { + let jpeg = make_test_jpeg(200, 150); + let mut y = extract_y_channel(&jpeg).unwrap(); + let region = central_region(&y); + + let original = read_block(&y, 0, 0, ®ion); + write_block(&mut y, 0, 0, ®ion, &original); + let after = read_block(&y, 0, 0, ®ion); + + for row in 0..8 { + for col in 0..8 { + assert_eq!(original[row][col], after[row][col]); + } + } + } + + #[test] + fn embed_extract_round_trip() { + let jpeg = make_test_jpeg(400, 300); + let secret: [u8; 32] = [ + 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, + ]; + + let stego = embed(&jpeg, &secret).unwrap(); + let extracted = extract(&stego).unwrap(); + assert_eq!(extracted, secret); + } + + #[test] + fn embed_extract_random_secret() { + use rand::RngCore; + let jpeg = make_test_jpeg(400, 300); + let mut secret = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret); + + let stego = embed(&jpeg, &secret).unwrap(); + let extracted = extract(&stego).unwrap(); + assert_eq!(extracted, secret); + } + + #[test] + fn extract_from_non_embedded_image_fails() { + let jpeg = make_test_jpeg(400, 300); + let result = extract(&jpeg); + assert!(result.is_err()); + } + + #[test] + fn image_too_small_fails() { + let jpeg = make_test_jpeg(32, 32); + let secret = [0u8; 32]; + let result = embed(&jpeg, &secret); + assert!(result.is_err()); + } + + #[test] + fn embed_extract_survives_recompression_q85() { + let jpeg = make_test_jpeg(400, 300); + let secret: [u8; 32] = [ + 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, + 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xAA, 0xBB, 0xCC, + ]; + + let stego = embed(&jpeg, &secret).unwrap(); + + // Re-encode at Q85 + let reader = ImageReader::new(Cursor::new(&stego)) + .with_guessed_format() + .unwrap(); + let img = reader.decode().unwrap(); + let rgb = img.to_rgb8(); + let (w, h) = (rgb.width(), rgb.height()); + let mut recompressed = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85); + encoder + .write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8) + .unwrap(); + + let extracted = extract(&recompressed).unwrap(); + assert_eq!(extracted, secret); + } + + #[test] + fn embed_extract_survives_10pct_crop() { + let jpeg = make_test_jpeg(400, 300); + let secret: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, + ]; + + let stego = embed(&jpeg, &secret).unwrap(); + + // Crop 10% from right edge + let reader = ImageReader::new(Cursor::new(&stego)) + .with_guessed_format() + .unwrap(); + let img = reader.decode().unwrap(); + let (w, h) = img.dimensions(); + let crop_pixels = (w as f64 * 0.10) as u32; + let cropped = img.crop_imm(0, 0, w - crop_pixels, h); + let rgb = cropped.to_rgb8(); + let (cw, ch) = (rgb.width(), rgb.height()); + + let mut cropped_jpeg = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92); + encoder + .write_image(rgb.as_raw(), cw, ch, image::ExtendedColorType::Rgb8) + .unwrap(); + + let extracted = extract(&cropped_jpeg).unwrap(); + assert_eq!(extracted, secret); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 954d1dc..f4c1a02 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -9,3 +9,5 @@ pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; pub mod vault; pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; + +pub mod imgsecret;