Zion Boggan zionboggan.com ↗

Add Rust image DCT watermarking

Co-authored-by: Codex (GPT-5.4) <noreply@openai.com>
03bad7b   Zion Boggan committed on May 19, 2026 (1 month ago)
CHANGELOG.md +5 -0
@@ -44,6 +44,11 @@
instead of raw literal scanning. The fallback handles `Tj`, `TJ`, quote
operators, and array spacing, with new Rust tests covering page-level PDF
text extraction.
+- **Rust image DCT parity.** `oversight-formats` now ports the Python image
+ adapter's DCT mid-band spread-spectrum watermarking path using `rustdct`.
+ Image watermarking writes the DCT mark and then preserves blind LSB recovery,
+ with tests for the Python-compatible mark sequence, DCT verification, and
+ adapter round trip.
## v0.4.11 - 2026-05-08 Hardware-keys completion: Python parity, browser support, end-to-end seal
README.md +6 -4
@@ -222,9 +222,11 @@ a fallback path for older manifests that lack those fields.
**Format watermark round-trip fixes.** `oversight-rust/oversight-formats`
text embedding now keeps L2 trailing-whitespace marks at physical
-line endings after L1 zero-width insertion, and image LSB embedding
+line endings after L1 zero-width insertion, image LSB embedding
no longer overwrites earlier payload bits via duplicate pixel
-slots. Workspace test suite is green again.
+slots, and current `main` adds DCT mid-band spread-spectrum image
+watermarking to match the Python reference path. Workspace test suite
+is green again.
## What's new in v0.4.8
@@ -421,7 +423,7 @@ project does not backport fixes below the current stable line.
| Python pytest suite | 10 | green |
| Rust oversight-container | 17 | green |
| Rust oversight-crypto | 21 | green |
-| Rust oversight-formats | 37 | green |
+| Rust oversight-formats | 40 | green |
| Rust oversight-manifest | 3 | green |
| Rust oversight-policy | 7 | green |
| Rust oversight-registry | 8 | green |
@@ -430,7 +432,7 @@ project does not backport fixes below the current stable line.
| Rust oversight-tlog | 7 | green |
| Rust oversight-watermark | 4 | green |
| Cross-language conformance | 3 | green |
-| Total automated Rust unit tests | 122 | all green |
+| Total automated Rust unit tests | 125 | all green |
## Design principles (what Oversight never does)
docs/EMBEDDING.md +1 -1
@@ -43,7 +43,7 @@ verifier and that may change without an embedding-API minor bump.
|---|---|
| `oversight-cli` | Desktop CLI binary. Pulls in TTY, file-tree, and Rich-formatted output. Not a library. |
| `oversight-registry` | Axum + SQLx registry server. Sender-side and operator-side; verifiers do not run a registry. |
-| `oversight-formats` | PDF, DOCX, image-LSB watermark application. Sender-only; pulls in heavy format dependencies that are wrong for a verifier binary's size budget. |
+| `oversight-formats` | PDF, DOCX, image DCT/LSB watermark application. Sender-only; pulls in heavy format dependencies that are wrong for a verifier binary's size budget. |
| `oversight-semantic` | L3 semantic synonym rotation. Sender-only; the verifier path uses `oversight-watermark` for L1/L2 detection only. |
A verifier-only embedder that finds it needs something from these crates is
docs/ROADMAP.md +2 -2
@@ -74,8 +74,8 @@ implemented as a Rust workspace under `oversight-rust/`. `cargo build
implementation; Rust is canonical for production deployments. A conformance
suite proves bit-identical output for every manifest and envelope. Format
adapter parity is being closed in bounded slices; current `main` has parsed
-PDF page/content-stream text extraction for fingerprinting instead of raw
-literal scanning.
+PDF page/content-stream text extraction for fingerprinting and DCT mid-band
+image watermarking in the Rust adapter.
### Fail-closed security hardening - v0.4.4
oversight-rust/Cargo.lock +67 -0
@@ -1373,12 +1373,30 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1463,6 +1481,7 @@ dependencies = [
"oversight-semantic",
"oversight-watermark",
"quick-xml",
+ "rustdct",
"sha2",
"thiserror 1.0.69",
"zip",
@@ -1695,6 +1714,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "primal-check"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
+dependencies = [
+ "num-integer",
+]
+
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -1850,6 +1878,29 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustdct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551"
+dependencies = [
+ "rustfft",
+]
+
+[[package]]
+name = "rustfft"
+version = "6.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "primal-check",
+ "strength_reduce",
+ "transpose",
+]
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -2222,6 +2273,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "strength_reduce"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -2522,6 +2579,16 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "transpose"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
+dependencies = [
+ "num-integer",
+ "strength_reduce",
+]
+
[[package]]
name = "typenum"
version = "1.20.0"
oversight-rust/oversight-formats/Cargo.toml +2 -1
@@ -25,12 +25,13 @@ quick-xml = { version = "0.36", features = ["serialize"], optional = true }
# Image pixel access
image = { version = "0.25", default-features = false, features = ["png", "jpeg"], optional = true }
+rustdct = { version = "0.7.1", optional = true }
[features]
default = ["text", "pdf", "docx", "image_fmt"]
text = []
pdf = ["dep:lopdf"]
docx = ["dep:zip", "dep:quick-xml"]
-image_fmt = ["dep:image"]
+image_fmt = ["dep:image", "dep:rustdct"]
[dev-dependencies]
oversight-rust/oversight-formats/src/image.rs +297 -53
@@ -1,63 +1,19 @@
//! # Image format adapter
//!
-//! LSB (Least Significant Bit) embedding in the Y (luma) channel of images.
-//!
-//! ## Algorithm
-//!
-//! The production Python adapter uses DCT-domain frequency watermarking (Cox
-//! et al. spread-spectrum). This Rust adapter uses a simpler LSB approach for
-//! the MVP, which is sufficient for controlled-distribution scenarios where
-//! the image won't be heavily recompressed.
-//!
-//! ### Embed
-//! 1. Decode image to RGB pixels.
-//! 2. Convert each pixel to YCbCr; take the Y (luma) channel.
-//! 3. Generate a deterministic bit sequence from `mark_id` using SHA-256.
-//! 4. For each bit, modify the LSB of the corresponding Y-channel pixel.
-//! 5. Convert back to RGB; encode as PNG (lossless).
-//!
-//! ### Extract
-//! 1. Decode image to RGB; extract Y channel.
-//! 2. Read LSBs from the same pixel positions.
-//! 3. Reconstruct the mark_id from the bit sequence.
-//!
-//! ## Security constraints
-//!
-//! - **Imperceptible**: LSB modification changes pixel values by at most 1
-//! in the luma channel. This is invisible to the human eye (below the
-//! just-noticeable difference threshold of ~2-3 levels for 8-bit luma).
-//! - **No executable content**: The adapter only modifies pixel data. No
-//! metadata, EXIF, ICC profiles, or ancillary chunks are injected.
-//!
-//! ## Survivability
-//!
-//! LSB embedding survives:
-//! - Format conversion (PNG <-> lossless formats)
-//! - Metadata stripping
-//!
-//! LSB embedding does NOT survive:
-//! - JPEG recompression (lossy)
-//! - Resizing / cropping
-//! - Any pixel-level transformation
-//!
-//! For JPEG-robust watermarking, use the DCT-domain approach from the Python
-//! adapter (requires `rustdct` or `realfft` crates -- roadmap item).
-//!
-//! ## TODO (v0.7 roadmap)
-//!
-//! - [ ] Port the full Cox et al. DCT spread-spectrum watermark from Python
-//! - [ ] Add perceptual hashing (pHash) for fuzzy leak-match
-//! - [ ] Support JPEG output with quality parameter
-//! - [ ] Add robustness testing against recompression
+//! DCT-domain image watermarking with blind LSB recovery support.
use crate::{FormatAdapter, FormatError, WatermarkCandidate};
-use image::{DynamicImage, GenericImageView, ImageFormat, Pixel};
+use image::{DynamicImage, GenericImageView, ImageFormat, Pixel, RgbImage};
+use rustdct::DctPlanner;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::io::Cursor;
/// Default mark_id length in bytes for extraction.
const MARK_LEN: usize = 8;
+const DEFAULT_DCT_ALPHA: f64 = 0.10;
+const DEFAULT_DCT_COEFFS: usize = 1500;
+const DEFAULT_DCT_THRESHOLD: f64 = 0.05;
/// Magic header prepended to the embedded bitstream for reliable extraction.
/// Without a header, extraction from an unmarked image would produce garbage
@@ -97,9 +53,8 @@ impl FormatAdapter for ImageAdapter {
}
fn embed_watermark(&self, data: &[u8], mark_id: &[u8]) -> Result<Vec<u8>, FormatError> {
- // Use blind-extract variant so extract_watermark works without
- // knowing the mark_id in advance.
- embed_lsb_blind(data, mark_id)
+ let dct_marked = embed_dct(data, mark_id)?;
+ embed_lsb_blind(&dct_marked, mark_id)
}
fn extract_watermark(&self, data: &[u8]) -> Result<Vec<WatermarkCandidate>, FormatError> {
@@ -137,6 +92,238 @@ fn load_image(data: &[u8]) -> Result<DynamicImage, FormatError> {
.map_err(|e| FormatError::Malformed(format!("image decode error: {}", e)))
}
+pub fn embed_dct(image_bytes: &[u8], mark_id: &[u8]) -> Result<Vec<u8>, FormatError> {
+ embed_dct_with_params(image_bytes, mark_id, DEFAULT_DCT_ALPHA, DEFAULT_DCT_COEFFS)
+}
+
+pub fn verify_dct(
+ image_bytes: &[u8],
+ candidate_mark_id: &[u8],
+) -> Result<(bool, f64), FormatError> {
+ verify_dct_with_params(
+ image_bytes,
+ candidate_mark_id,
+ DEFAULT_DCT_THRESHOLD,
+ DEFAULT_DCT_COEFFS,
+ )
+}
+
+pub fn embed_dct_with_params(
+ image_bytes: &[u8],
+ mark_id: &[u8],
+ alpha: f64,
+ n_coeffs: usize,
+) -> Result<Vec<u8>, FormatError> {
+ if mark_id.is_empty() {
+ return Err(FormatError::EmbedFailed("mark_id cannot be empty".into()));
+ }
+ let img = load_image(image_bytes)?;
+ let mut planes = image_to_ycbcr(&img);
+ let coords = pick_midband_indices(planes.width, planes.height, n_coeffs);
+ if coords.is_empty() {
+ return Err(FormatError::EmbedFailed(
+ "image too small for DCT watermark".into(),
+ ));
+ }
+
+ dct2_2d(&mut planes.y, planes.width as usize, planes.height as usize);
+ let sequence = mark_to_sequence(mark_id, coords.len());
+ let width = planes.width as usize;
+ for ((row, col), bit) in coords.iter().zip(sequence.iter()) {
+ let idx = row * width + col;
+ let mag = planes.y[idx].abs();
+ planes.y[idx] += alpha * mag * bit;
+ }
+ idct2_2d(&mut planes.y, planes.width as usize, planes.height as usize);
+
+ let rgb = ycbcr_to_rgb_image(&planes);
+ let mut output = Cursor::new(Vec::new());
+ DynamicImage::ImageRgb8(rgb)
+ .write_to(&mut output, ImageFormat::Png)
+ .map_err(|e| FormatError::EmbedFailed(format!("PNG encode error: {}", e)))?;
+ Ok(output.into_inner())
+}
+
+pub fn verify_dct_with_params(
+ image_bytes: &[u8],
+ candidate_mark_id: &[u8],
+ threshold: f64,
+ n_coeffs: usize,
+) -> Result<(bool, f64), FormatError> {
+ if candidate_mark_id.is_empty() {
+ return Ok((false, 0.0));
+ }
+ let img = load_image(image_bytes)?;
+ let mut planes = image_to_ycbcr(&img);
+ let coords = pick_midband_indices(planes.width, planes.height, n_coeffs);
+ if coords.is_empty() {
+ return Ok((false, 0.0));
+ }
+
+ dct2_2d(&mut planes.y, planes.width as usize, planes.height as usize);
+ let sequence = mark_to_sequence(candidate_mark_id, coords.len());
+ let width = planes.width as usize;
+ let mut numerator = 0.0;
+ let mut denominator = 0.0;
+ for ((row, col), expected) in coords.iter().zip(sequence.iter()) {
+ let val = planes.y[row * width + col];
+ numerator += val * expected;
+ denominator += val.abs();
+ }
+ let score = numerator / (denominator + 1e-9);
+ Ok((score > 0.0 && score.abs() >= threshold, score))
+}
+
+struct YCbCrPlanes {
+ width: u32,
+ height: u32,
+ y: Vec<f64>,
+ cb: Vec<f64>,
+ cr: Vec<f64>,
+}
+
+fn image_to_ycbcr(img: &DynamicImage) -> YCbCrPlanes {
+ let rgb = img.to_rgb8();
+ let (width, height) = rgb.dimensions();
+ let len = (width as usize) * (height as usize);
+ let mut y = Vec::with_capacity(len);
+ let mut cb = Vec::with_capacity(len);
+ let mut cr = Vec::with_capacity(len);
+ for pixel in rgb.pixels() {
+ let [r, g, b] = pixel.0;
+ let (yy, cc_b, cc_r) = rgb_to_ycbcr(r, g, b);
+ y.push(yy);
+ cb.push(cc_b);
+ cr.push(cc_r);
+ }
+ YCbCrPlanes {
+ width,
+ height,
+ y,
+ cb,
+ cr,
+ }
+}
+
+fn ycbcr_to_rgb_image(planes: &YCbCrPlanes) -> RgbImage {
+ RgbImage::from_fn(planes.width, planes.height, |x, y| {
+ let idx = (y as usize) * (planes.width as usize) + (x as usize);
+ let (r, g, b) = ycbcr_to_rgb(planes.y[idx], planes.cb[idx], planes.cr[idx]);
+ image::Rgb([r, g, b])
+ })
+}
+
+fn rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
+ let r = r as f64;
+ let g = g as f64;
+ let b = b as f64;
+ (
+ 0.299 * r + 0.587 * g + 0.114 * b,
+ 128.0 - 0.168_736 * r - 0.331_264 * g + 0.5 * b,
+ 128.0 + 0.5 * r - 0.418_688 * g - 0.081_312 * b,
+ )
+}
+
+fn ycbcr_to_rgb(y: f64, cb: f64, cr: f64) -> (u8, u8, u8) {
+ let cb = cb - 128.0;
+ let cr = cr - 128.0;
+ (
+ clamp_u8(y + 1.402 * cr),
+ clamp_u8(y - 0.344_136 * cb - 0.714_136 * cr),
+ clamp_u8(y + 1.772 * cb),
+ )
+}
+
+fn clamp_u8(v: f64) -> u8 {
+ v.round().clamp(0.0, 255.0) as u8
+}
+
+fn dct2_2d(data: &mut [f64], width: usize, height: usize) {
+ let mut planner = DctPlanner::new();
+ let row_dct = planner.plan_dct2(width);
+ let col_dct = planner.plan_dct2(height);
+ let mut row_scratch = vec![0.0; row_dct.get_scratch_len()];
+ for row in data.chunks_mut(width) {
+ row_dct.process_dct2_with_scratch(row, &mut row_scratch);
+ }
+ let mut column = vec![0.0; height];
+ let mut col_scratch = vec![0.0; col_dct.get_scratch_len()];
+ for x in 0..width {
+ for y in 0..height {
+ column[y] = data[y * width + x];
+ }
+ col_dct.process_dct2_with_scratch(&mut column, &mut col_scratch);
+ for y in 0..height {
+ data[y * width + x] = column[y];
+ }
+ }
+}
+
+fn idct2_2d(data: &mut [f64], width: usize, height: usize) {
+ let mut planner = DctPlanner::new();
+ let col_dct = planner.plan_dct3(height);
+ let row_dct = planner.plan_dct3(width);
+ let mut column = vec![0.0; height];
+ let mut col_scratch = vec![0.0; col_dct.get_scratch_len()];
+ for x in 0..width {
+ for y in 0..height {
+ column[y] = data[y * width + x];
+ }
+ col_dct.process_dct3_with_scratch(&mut column, &mut col_scratch);
+ for y in 0..height {
+ data[y * width + x] = column[y];
+ }
+ }
+ let mut row_scratch = vec![0.0; row_dct.get_scratch_len()];
+ for row in data.chunks_mut(width) {
+ row_dct.process_dct3_with_scratch(row, &mut row_scratch);
+ }
+ let scale = 4.0 / ((width as f64) * (height as f64));
+ for value in data {
+ *value *= scale;
+ }
+}
+
+fn pick_midband_indices(width: u32, height: u32, n: usize) -> Vec<(usize, usize)> {
+ let limit = width.min(height) as usize;
+ let lo = ((limit as f64) * 0.10) as usize;
+ let hi = ((limit as f64) * 0.40) as usize;
+ let mut coords = Vec::new();
+ for row in 0..height as usize {
+ for col in 0..width as usize {
+ let diagonal = row + col;
+ if diagonal >= lo && diagonal <= hi {
+ coords.push((row, col));
+ if coords.len() >= n {
+ return coords;
+ }
+ }
+ }
+ }
+ coords
+}
+
+fn mark_to_sequence(mark_id: &[u8], length: usize) -> Vec<f64> {
+ let mut out = Vec::with_capacity(length);
+ let mut counter: u32 = 0;
+ while out.len() < length {
+ let mut h = Sha256::new();
+ h.update(mark_id);
+ h.update(counter.to_be_bytes());
+ let digest = h.finalize();
+ for byte in digest {
+ for bit in 0..8 {
+ if out.len() >= length {
+ break;
+ }
+ out.push(if ((byte >> bit) & 1) == 1 { 1.0 } else { -1.0 });
+ }
+ }
+ counter = counter.wrapping_add(1);
+ }
+ out
+}
+
// ---------------------------------------------------------------------------
// RGB <-> YCbCr conversion (integer approximation, BT.601)
// ---------------------------------------------------------------------------
@@ -466,6 +653,51 @@ mod tests {
assert_eq!(recovered, data);
}
+ #[test]
+ fn dct_mark_sequence_matches_python_fixture() {
+ let seq = mark_to_sequence(&hex::decode("0102030405060708").unwrap(), 16);
+ assert_eq!(
+ seq,
+ vec![
+ 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 1.0,
+ -1.0
+ ]
+ );
+ }
+
+ #[test]
+ fn dct_embed_verify_round_trip() {
+ let png_bytes = gradient_png(128, 128);
+ let mark_id = b"\x01\x02\x03\x04\x05\x06\x07\x08";
+ let marked = embed_dct_with_params(&png_bytes, mark_id, 0.20, 1000).unwrap();
+ let (matched, score) = verify_dct(&marked, mark_id).unwrap();
+ assert!(matched, "expected DCT match, score={}", score);
+
+ let wrong = b"\x08\x07\x06\x05\x04\x03\x02\x01";
+ let (wrong_matched, wrong_score) =
+ verify_dct_with_params(&marked, wrong, 0.08, 1000).unwrap();
+ assert!(
+ !wrong_matched,
+ "wrong mark should not verify, score={}",
+ wrong_score
+ );
+ }
+
+ #[test]
+ fn adapter_image_embed_carries_blind_and_dct_marks() {
+ let adapter = ImageAdapter;
+ let png_bytes = gradient_png(128, 128);
+ let mark_id = b"\xde\xad\xbe\xef\xca\xfe\xba\xbe";
+ let marked = adapter.embed_watermark(&png_bytes, mark_id).unwrap();
+
+ let candidates = adapter.extract_watermark(&marked).unwrap();
+ assert_eq!(candidates.len(), 1);
+ assert_eq!(candidates[0].mark_id, mark_id);
+
+ let (matched, score) = verify_dct_with_params(&marked, mark_id, 0.03, 1000).unwrap();
+ assert!(matched, "expected DCT match, score={}", score);
+ }
+
#[test]
fn y_channel_lsb_flip() {
// Test that set_y_lsb correctly sets the LSB
@@ -563,4 +795,16 @@ mod tests {
max_diff
);
}
+
+ fn gradient_png(width: u32, height: u32) -> Vec<u8> {
+ let img = image::RgbaImage::from_fn(width, height, |x, y| {
+ let r = ((x * 3 + y * 5) % 256) as u8;
+ let g = ((x * 7 + y * 11) % 256) as u8;
+ let b = ((x * 13 + y * 17) % 256) as u8;
+ image::Rgba([r, g, b, 255])
+ });
+ let mut buf = Cursor::new(Vec::new());
+ img.write_to(&mut buf, ImageFormat::Png).unwrap();
+ buf.into_inner()
+ }
}
oversight-rust/oversight-formats/src/lib.rs +3 -3
@@ -10,9 +10,9 @@
//! ## Adapters
//!
//! - **text** -- L1 zero-width + L2 whitespace + L3 semantic (fully functional)
-//! - **pdf** -- PDF metadata injection via `lopdf` (scaffold)
-//! - **docx** -- Office OOXML core properties via `zip` + `quick-xml` (scaffold)
-//! - **image** -- LSB embedding in Y channel (scaffold)
+//! - **pdf** -- PDF metadata injection and parsed text extraction via `lopdf`
+//! - **docx** -- Office OOXML core properties via `zip` + `quick-xml`
+//! - **image** -- DCT mid-band watermarking plus blind LSB recovery
//!
//! ## Usage
//!