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) <noreply@anthropic.com>
This commit is contained in:
765
crates/idfoto-core/src/imgsecret.rs
Normal file
765
crates/idfoto-core/src/imgsecret.rs
Normal file
@@ -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<f64>,
|
||||||
|
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<YChannel> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<u8> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,5 @@ pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
|||||||
|
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||||||
|
|
||||||
|
pub mod imgsecret;
|
||||||
|
|||||||
Reference in New Issue
Block a user