Skip to main content

phasm_core/stego/
mod.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Steganographic encoding and decoding pipelines.
6//!
7//! This module provides two embedding modes:
8//!
9//! - **Ghost** (`ghost_encode` / `ghost_decode`): Stealth mode using J-UNIWARD
10//!   cost function and Syndrome-Trellis Coding (STC) to minimize statistical
11//!   detectability. Best for images that will not be recompressed.
12//!
13//! - **Armor** (`armor_encode` / `armor_decode`): Robustness mode using STDM
14//!   (Spread Transform Dither Modulation) with Reed-Solomon error correction to
15//!   survive JPEG recompression. Trades capacity for survivability.
16//!
17//! Both modes share the same payload frame format, encryption (AES-256-GCM-SIV),
18//! and key derivation (Argon2id two-tier). The `smart_decode` function
19//! auto-detects which mode was used.
20
21// --- Media-agnostic modules (shared by image and video pipelines) ---
22pub mod error;
23pub mod stc;
24pub mod crypto;
25pub mod frame;
26pub mod permute;
27pub mod payload;
28pub mod progress;
29pub mod shadow_layer;
30
31// --- Steganographic algorithms ---
32pub mod cost;
33pub(crate) mod ghost;
34pub mod armor;
35#[cfg(feature = "video")]
36pub mod video;
37
38pub use error::StegoError;
39pub use ghost::quality;
40pub use ghost::quality::EncodeQuality;
41pub use ghost::optimizer::{optimize_cover, OptimizerConfig, OptimizerMode};
42
43// Backward-compatible re-exports at original paths
44pub use ghost::capacity;
45pub use ghost::side_info;
46pub use ghost::shadow;
47pub use ghost::optimizer;
48
49/// Maximum pixel dimension (width or height) for encode.
50/// Images exceeding this are downsampled by the frontend before reaching Rust.
51pub const MAX_DIMENSION: u32 = 16384;
52
53/// Maximum total pixel count for encode (width × height).
54/// 200 MP covers all current cameras including flagship 200 MP sensors.
55/// Memory-optimized: strip-based UNIWARD (~170 MB/strip), compact positions
56/// (8 bytes each), segmented STC Viterbi. Total ~1 GB for 200 MP.
57pub const MAX_PIXELS: u32 = 200_000_000;
58
59/// Minimum pixel dimension (width or height) for encode.
60/// Images below this are rejected with an error message.
61pub const MIN_ENCODE_DIMENSION: u32 = 200;
62
63/// Target pixel dimension (longest side) for Armor/Fortress pre-resize.
64/// Images larger than this are downsampled by the frontend before encoding
65/// in Armor mode, so that the 8×8 block grid survives platform recompression
66/// (e.g. WhatsApp resizes to ~1600px on the longest side).
67pub const ARMOR_TARGET_DIMENSION: u32 = 1600;
68
69/// Validate image dimensions for encoding.
70///
71/// Returns `Ok(())` if the dimensions are within acceptable bounds.
72/// Called at the start of both `ghost_encode` and `armor_encode`.
73///
74/// # Errors
75/// - [`StegoError::ImageTooSmall`] if either dimension < 200px.
76/// - [`StegoError::ImageTooLarge`] if either dimension > 8192px or total pixels > 16M.
77pub fn validate_encode_dimensions(width: u32, height: u32) -> Result<(), StegoError> {
78    if width < MIN_ENCODE_DIMENSION || height < MIN_ENCODE_DIMENSION {
79        return Err(StegoError::ImageTooSmall);
80    }
81    if width > MAX_DIMENSION || height > MAX_DIMENSION || width.checked_mul(height).is_none_or(|p| p > MAX_PIXELS) {
82        return Err(StegoError::ImageTooLarge);
83    }
84    Ok(())
85}
86pub use ghost::pipeline::{ghost_encode, ghost_decode, ghost_encode_with_files, ghost_encode_si, ghost_encode_si_with_files, GHOST_DECODE_STEPS, GHOST_ENCODE_STEPS};
87pub use ghost::pipeline::{ghost_encode_with_quality, ghost_encode_with_files_quality, ghost_encode_si_with_quality, ghost_encode_si_with_files_quality};
88pub use ghost::pipeline::{ghost_encode_with_shadows, ghost_encode_si_with_shadows, ghost_shadow_decode, ShadowLayer, GHOST_ENCODE_WITH_SHADOWS_STEPS};
89pub use ghost::pipeline::{ghost_encode_with_shadows_quality, ghost_encode_si_with_shadows_quality};
90pub use shadow::shadow_capacity;
91pub use capacity::estimate_shadow_capacity;
92pub use capacity::estimate_capacity as ghost_capacity;
93pub use capacity::estimate_capacity_si as ghost_capacity_si;
94pub use capacity::estimate_capacity_with_shadows as ghost_capacity_with_shadows;
95pub use armor::pipeline::{armor_encode, armor_encode_with_quality, armor_decode, DecodeQuality, ArmorCapacityInfo, armor_capacity_info};
96pub use armor::capacity::estimate_armor_capacity as armor_capacity;
97pub use payload::{PayloadData, FileEntry, compressed_payload_size};
98
99#[cfg(test)]
100mod dimension_tests {
101    use super::*;
102
103    #[test]
104    fn valid_dimensions() {
105        assert!(validate_encode_dimensions(800, 600).is_ok());
106        assert!(validate_encode_dimensions(3000, 4000).is_ok());
107    }
108
109    #[test]
110    fn boundary_min() {
111        assert!(validate_encode_dimensions(200, 200).is_ok());
112        assert!(validate_encode_dimensions(199, 200).is_err());
113        assert!(validate_encode_dimensions(200, 199).is_err());
114    }
115
116    #[test]
117    fn boundary_max_dimension() {
118        assert!(validate_encode_dimensions(16384, 1000).is_ok());
119        assert!(validate_encode_dimensions(1000, 16384).is_ok());
120        assert!(validate_encode_dimensions(16385, 1000).is_err());
121        assert!(validate_encode_dimensions(1000, 16385).is_err());
122    }
123
124    #[test]
125    fn too_many_pixels() {
126        // 14143 * 14143 = 200_024_449 > 200M
127        assert!(validate_encode_dimensions(14143, 14143).is_err());
128        // 14142 * 14142 = 199_996_164 < 200M — OK
129        assert!(validate_encode_dimensions(14142, 14142).is_ok());
130    }
131
132    #[test]
133    fn error_variants() {
134        match validate_encode_dimensions(100, 300) {
135            Err(StegoError::ImageTooSmall) => {}
136            other => panic!("expected ImageTooSmall, got {other:?}"),
137        }
138        match validate_encode_dimensions(16385, 1000) {
139            Err(StegoError::ImageTooLarge) => {}
140            other => panic!("expected ImageTooLarge, got {other:?}"),
141        }
142    }
143}
144
145/// Unified decode: auto-detects Ghost or Armor mode from the embedded frame.
146///
147/// Tries Ghost first, then Armor. Returns the decoded payload and quality info.
148///
149/// When the `parallel` feature is enabled, Ghost and Armor decodes run
150/// concurrently via `rayon::join`, roughly halving decode latency on
151/// multi-core devices.
152pub fn smart_decode(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
153    let result = smart_decode_inner(stego_bytes, passphrase);
154    progress::finish();
155    result
156}
157
158/// Serial smart_decode implementation (default path and WASM).
159///
160/// Tries Armor first (default mode, most common), then Ghost.
161/// Progress steps: 1 (fortress) + ~21 (phase1) + ~21 (phase2) + 1 (phase3)
162///   + GHOST_DECODE_STEPS (102: 100 UNIWARD + 2 STC/decrypt).
163/// Actual total is set by try_armor_decode once candidate count is known.
164#[cfg(not(feature = "parallel"))]
165fn smart_decode_inner(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
166    progress::init(0); // reset; try_armor_decode sets real total
167
168    progress::check_cancelled()?;
169
170    let mut saw_decryption_failed = false;
171
172    // Try Armor first (default mode, most likely)
173    match armor_decode(stego_bytes, passphrase) {
174        Ok((payload, quality)) => return Ok((payload, quality)),
175        Err(StegoError::DecryptionFailed) => {
176            saw_decryption_failed = true;
177            // Could be wrong passphrase for Armor — still try Ghost
178        }
179        Err(StegoError::FrameCorrupted) => {
180            // Likely not Armor — try Ghost
181        }
182        Err(e) => {
183            // Fundamental error (bad JPEG, too small, etc.) — try Ghost anyway
184            // in case Armor fails for mode-specific reasons
185            match ghost_decode(stego_bytes, passphrase) {
186                Ok(payload) => return Ok((payload, DecodeQuality::ghost())),
187                Err(_) => return Err(e), // Return original Armor error
188            }
189        }
190    }
191
192    // Try Ghost — extend progress total instead of resetting to avoid the
193    // bar jumping back to 0%.  The bar continues smoothly from the Armor
194    // phase into the Ghost phase.
195    let (armor_done, _) = progress::get();
196    progress::set_total(armor_done + GHOST_DECODE_STEPS as u32);
197    let ghost_result = ghost_decode(stego_bytes, passphrase);
198    match ghost_result {
199        Ok(payload) => return Ok((payload, DecodeQuality::ghost())),
200        Err(StegoError::DecryptionFailed) => {
201            saw_decryption_failed = true;
202        }
203        Err(_) => {}
204    }
205
206    // Try Ghost shadow (Y-channel direct LSB + RS)
207    match ghost::pipeline::ghost_shadow_decode(stego_bytes, passphrase) {
208        Ok(payload) => return Ok((payload, DecodeQuality::ghost())),
209        Err(StegoError::DecryptionFailed) => {
210            saw_decryption_failed = true;
211        }
212        Err(_) => {}
213    }
214
215    if saw_decryption_failed {
216        Err(StegoError::DecryptionFailed)
217    } else {
218        Err(StegoError::FrameCorrupted)
219    }
220}
221
222/// Parallel smart_decode: three-way concurrent decode via rayon.
223///
224/// Parses the JPEG once and shares `&JpegImage` across threads.
225/// Runs Fortress, STDM+Phase3, and Ghost in parallel.
226/// Preference order: Fortress > Armor STDM > Ghost.
227#[cfg(feature = "parallel")]
228fn smart_decode_inner(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
229    use crate::codec::jpeg::JpegImage;
230    use crate::stego::armor::fortress;
231    use crate::stego::armor::pipeline::armor_decode_no_fortress;
232
233    // In parallel mode all three branches advance the same global counter
234    // concurrently.  We init with 0 (indeterminate) — try_armor_decode will
235    // set a real total once it knows the candidate count.  The cap in
236    // advance() prevents step from ever exceeding total.
237    progress::init(0);
238    progress::check_cancelled()?;
239
240    let img = JpegImage::from_bytes(stego_bytes)?;
241
242    let (fortress_result, (stdm_result, (ghost_result, shadow_result))) = rayon::join(
243        || {
244            if img.num_components() > 0 {
245                fortress::fortress_decode(&img, passphrase)
246            } else {
247                Err(StegoError::FrameCorrupted)
248            }
249        },
250        || rayon::join(
251            || armor_decode_no_fortress(&img, stego_bytes, passphrase),
252            || rayon::join(
253                || ghost_decode(stego_bytes, passphrase),
254                || ghost::pipeline::ghost_shadow_decode_from_image(&img, passphrase),
255            ),
256        ),
257    );
258
259    // Prefer Fortress (fastest, most robust).
260    if let Ok((payload, quality)) = fortress_result {
261        return Ok((payload, quality));
262    }
263
264    // Try Armor STDM + Phase 3.
265    if let Ok((payload, quality)) = stdm_result {
266        return Ok((payload, quality));
267    }
268
269    // Try Ghost.
270    if let Ok(payload) = ghost_result {
271        return Ok((payload, DecodeQuality::ghost()));
272    }
273
274    // Try Ghost shadow.
275    if let Ok(payload) = shadow_result {
276        return Ok((payload, DecodeQuality::ghost()));
277    }
278
279    // All failed — determine the best error to report.
280    let saw_decryption_failed = matches!(&fortress_result, Err(StegoError::DecryptionFailed))
281        || matches!(&stdm_result, Err(StegoError::DecryptionFailed))
282        || matches!(&ghost_result, Err(StegoError::DecryptionFailed))
283        || matches!(&shadow_result, Err(StegoError::DecryptionFailed));
284
285    if saw_decryption_failed {
286        return Err(StegoError::DecryptionFailed);
287    }
288
289    Err(stdm_result.unwrap_err())
290}