Skip to main content

phasm_core/stego/
shadow_layer.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Media-agnostic shadow-layer primitives.
6//!
7//! Shadow messages provide plausible deniability: multiple messages
8//! can be hidden in a single cover (image, video, …), each with a
9//! different passphrase. The frame format, parity tier ladder, RS
10//! encoding shape, and capacity arithmetic are the same regardless
11//! of medium. Per-medium specifics (which positions to embed at,
12//! how to write into them, how to walk the cover at decode time)
13//! live in the medium-specific modules:
14//!
15//! - **Image** (Y-channel JPEG nzAC, cost-pool + hash priority):
16//!   `core/src/stego/ghost/shadow.rs`
17//! - **Video H.264** (4 bypass-bin domains, hash priority + additive
18//!   bias for N>1): `core/src/codec/h264/stego/shadow.rs`
19//!
20//! ## Frame formats (no header, fdl recovered via first-block peek)
21//!
22//! Two variants exist, distinguished by the width of the
23//! plaintext-length prefix:
24//!
25//! **Standard (image)** — `SHADOW_FRAME_OVERHEAD = 46 bytes`:
26//! ```text
27//! [plaintext_len: 2B u16 BE] [salt: 16B] [nonce: 12B] [ciphertext: N+16B]
28//! ```
29//! Used by image stego (`core/src/stego/ghost/shadow.rs`). Hard
30//! cap: 65,535-byte plaintext per shadow. Adequate for image
31//! covers (Y-channel JPEG nzAC capacity ≈ tens of KB).
32//!
33//! **Wide (video)** — `SHADOW_FRAME_OVERHEAD_WIDE = 48 bytes`:
34//! ```text
35//! [plaintext_len: 4B u32 BE] [salt: 16B] [nonce: 12B] [ciphertext: N+16B]
36//! ```
37//! Used by H.264 video stego
38//! (`core/src/codec/h264/stego/shadow.rs`). Hard cap:
39//! 4,294,967,295-byte plaintext per shadow. Required because
40//! video covers can support much larger payloads (file
41//! attachments, multi-MB shadows). The two formats are wire-
42//! incompatible by design — image stego has been released and the
43//! u16 layout is locked; video stego is pre-release and uses the
44//! wider layout natively.
45//!
46//! No magic byte; AES-256-GCM-SIV authentication is the only
47//! validator. Decoders brute-force `(parity, fdl)` combinations
48//! using a first-block-peek heuristic to derive `fdl` from the
49//! plaintext-length prefix once the first 255-byte RS block decodes.
50
51use crate::stego::crypto::{NONCE_LEN, SALT_LEN};
52use crate::stego::error::StegoError;
53use crate::stego::payload::FileEntry;
54
55/// One shadow layer's input — message + passphrase + optional file
56/// attachments. The encoder takes a slice of these (size-descending
57/// for primary-vs-shadow ordering by message size).
58pub struct ShadowLayer<'a> {
59    pub message: &'a str,
60    pub passphrase: &'a str,
61    pub files: &'a [FileEntry],
62}
63
64/// Standard (image) frame overhead inside the RS-encoded payload:
65/// `plaintext_len(2) + salt(16) + nonce(12) + tag(16) = 46 bytes`.
66/// Used by image stego (`stego::ghost::shadow`); locked at u16 to
67/// preserve compatibility with released app versions.
68pub const SHADOW_FRAME_OVERHEAD: usize = 2 + SALT_LEN + NONCE_LEN + 16;
69
70/// Wide (video) frame overhead — same fields but with a u32
71/// plaintext-length prefix:
72/// `plaintext_len(4) + salt(16) + nonce(12) + tag(16) = 48 bytes`.
73/// Used by H.264 video stego — covers can support multi-MB
74/// shadows (file attachments) so the u16 cap is too tight.
75pub const SHADOW_FRAME_OVERHEAD_WIDE: usize = 4 + SALT_LEN + NONCE_LEN + 16;
76
77/// RS parity tiers. Brute-forced at decode.
78pub const SHADOW_PARITY_TIERS: [usize; 6] = [4, 8, 16, 32, 64, 128];
79
80/// Maximum RS-encoded frame bytes for the standard (image) format
81/// — guards against unreasonable allocations during decode
82/// brute-force. Implies plaintext ≤ ~256 KB before RS expansion;
83/// in practice u16 caps it at 65,535 bytes.
84pub const MAX_SHADOW_FRAME_BYTES: usize = 256 * 1024;
85
86/// Maximum RS-encoded frame bytes for the wide (video) format.
87/// Bumped to 16 MB to accommodate plausible large attachments
88/// (e.g., embedded photos as shadows in long videos). Decoder
89/// brute-force scan is bounded; this is the safety upper bound.
90pub const MAX_SHADOW_FRAME_BYTES_WIDE: usize = 16 * 1024 * 1024;
91
92/// Build the shadow inner frame (before RS encoding).
93///
94/// Layout: `[plaintext_len: 2B] [salt: 16B] [nonce: 12B]
95///          [ciphertext: N+16B]`
96pub fn build_shadow_frame(
97    plaintext_len: usize,
98    salt: &[u8; SALT_LEN],
99    nonce: &[u8; NONCE_LEN],
100    ciphertext: &[u8],
101) -> Vec<u8> {
102    assert!(
103        plaintext_len <= u16::MAX as usize,
104        "shadow frame plaintext exceeds u16::MAX",
105    );
106    let mut fr = Vec::with_capacity(SHADOW_FRAME_OVERHEAD + plaintext_len);
107    fr.extend_from_slice(&(plaintext_len as u16).to_be_bytes());
108    fr.extend_from_slice(salt);
109    fr.extend_from_slice(nonce);
110    fr.extend_from_slice(ciphertext);
111    fr
112}
113
114/// Parsed shadow frame — output of [`parse_shadow_frame`].
115pub struct ParsedShadowFrame {
116    pub plaintext_len: u16,
117    pub salt: [u8; SALT_LEN],
118    pub nonce: [u8; NONCE_LEN],
119    pub ciphertext: Vec<u8>,
120}
121
122/// Parse a shadow inner frame (after RS decoding).
123pub fn parse_shadow_frame(data: &[u8]) -> Result<ParsedShadowFrame, StegoError> {
124    if data.len() < SHADOW_FRAME_OVERHEAD {
125        return Err(StegoError::FrameCorrupted);
126    }
127    let plaintext_len = u16::from_be_bytes([data[0], data[1]]);
128    let expected_len = SHADOW_FRAME_OVERHEAD + plaintext_len as usize;
129    if data.len() < expected_len {
130        return Err(StegoError::FrameCorrupted);
131    }
132    let mut salt = [0u8; SALT_LEN];
133    salt.copy_from_slice(&data[2..2 + SALT_LEN]);
134    let mut nonce = [0u8; NONCE_LEN];
135    nonce.copy_from_slice(&data[2 + SALT_LEN..2 + SALT_LEN + NONCE_LEN]);
136    let ciphertext = data[2 + SALT_LEN + NONCE_LEN..expected_len].to_vec();
137    Ok(ParsedShadowFrame { plaintext_len, salt, nonce, ciphertext })
138}
139
140/// Build the wide shadow inner frame (u32 plaintext_len) — see
141/// module docs for layout. Used by video stego.
142pub fn build_shadow_frame_wide(
143    plaintext_len: usize,
144    salt: &[u8; SALT_LEN],
145    nonce: &[u8; NONCE_LEN],
146    ciphertext: &[u8],
147) -> Vec<u8> {
148    assert!(
149        plaintext_len <= u32::MAX as usize,
150        "shadow frame plaintext exceeds u32::MAX",
151    );
152    let mut fr = Vec::with_capacity(SHADOW_FRAME_OVERHEAD_WIDE + plaintext_len);
153    fr.extend_from_slice(&(plaintext_len as u32).to_be_bytes());
154    fr.extend_from_slice(salt);
155    fr.extend_from_slice(nonce);
156    fr.extend_from_slice(ciphertext);
157    fr
158}
159
160/// Parsed wide shadow frame — output of [`parse_shadow_frame_wide`].
161pub struct ParsedShadowFrameWide {
162    pub plaintext_len: u32,
163    pub salt: [u8; SALT_LEN],
164    pub nonce: [u8; NONCE_LEN],
165    pub ciphertext: Vec<u8>,
166}
167
168/// Parse a wide shadow inner frame (u32 plaintext_len). Used by
169/// video stego.
170pub fn parse_shadow_frame_wide(data: &[u8]) -> Result<ParsedShadowFrameWide, StegoError> {
171    if data.len() < SHADOW_FRAME_OVERHEAD_WIDE {
172        return Err(StegoError::FrameCorrupted);
173    }
174    let plaintext_len =
175        u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
176    let expected_len =
177        SHADOW_FRAME_OVERHEAD_WIDE + plaintext_len as usize;
178    if data.len() < expected_len {
179        return Err(StegoError::FrameCorrupted);
180    }
181    let mut salt = [0u8; SALT_LEN];
182    salt.copy_from_slice(&data[4..4 + SALT_LEN]);
183    let mut nonce = [0u8; NONCE_LEN];
184    nonce.copy_from_slice(&data[4 + SALT_LEN..4 + SALT_LEN + NONCE_LEN]);
185    let ciphertext =
186        data[4 + SALT_LEN + NONCE_LEN..expected_len].to_vec();
187    Ok(ParsedShadowFrameWide { plaintext_len, salt, nonce, ciphertext })
188}
189
190/// Compute the maximum `frame_data_len` (bytes before RS encoding)
191/// that fits in `max_rs_bytes` of available LSB capacity at the
192/// given parity length.
193pub fn compute_max_shadow_fdl(max_rs_bytes: usize, parity_len: usize) -> usize {
194    let k = 255usize.saturating_sub(parity_len);
195    if k == 0 || max_rs_bytes == 0 {
196        return 0;
197    }
198    let full_blocks = max_rs_bytes / 255;
199    let remainder = max_rs_bytes % 255;
200    let mut max_data = full_blocks * k;
201    if remainder > parity_len {
202        max_data += remainder - parity_len;
203    }
204    max_data
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn frame_roundtrip() {
213        let salt = [1u8; SALT_LEN];
214        let nonce = [2u8; NONCE_LEN];
215        let ciphertext = vec![0xAAu8; 20];
216        let fr = build_shadow_frame(4, &salt, &nonce, &ciphertext);
217        let parsed = parse_shadow_frame(&fr).unwrap();
218        assert_eq!(parsed.plaintext_len, 4);
219        assert_eq!(parsed.salt, salt);
220        assert_eq!(parsed.nonce, nonce);
221        assert_eq!(parsed.ciphertext, ciphertext);
222    }
223
224    #[test]
225    fn frame_wide_roundtrip() {
226        let salt = [3u8; SALT_LEN];
227        let nonce = [4u8; NONCE_LEN];
228        let ciphertext = vec![0xBBu8; 20];
229        let fr = build_shadow_frame_wide(4, &salt, &nonce, &ciphertext);
230        assert_eq!(fr.len(), SHADOW_FRAME_OVERHEAD_WIDE + ciphertext.len() - 16);
231        let parsed = parse_shadow_frame_wide(&fr).unwrap();
232        assert_eq!(parsed.plaintext_len, 4);
233        assert_eq!(parsed.salt, salt);
234        assert_eq!(parsed.nonce, nonce);
235        assert_eq!(parsed.ciphertext, ciphertext);
236    }
237
238    /// The wide format must be wire-incompatible with the standard
239    /// format (different overhead, different length-field width).
240    /// Sanity-check that constants reflect the expected byte counts.
241    #[test]
242    fn wide_overhead_two_bytes_more_than_standard() {
243        assert_eq!(SHADOW_FRAME_OVERHEAD, 46);
244        assert_eq!(SHADOW_FRAME_OVERHEAD_WIDE, 48);
245        assert_eq!(SHADOW_FRAME_OVERHEAD_WIDE - SHADOW_FRAME_OVERHEAD, 2);
246    }
247
248    /// A frame written via the standard builder must NOT parse via
249    /// the wide parser (they're distinct formats; mixing decoders
250    /// would silently produce garbage).
251    #[test]
252    fn wide_parser_rejects_standard_frame_layout() {
253        let salt = [5u8; SALT_LEN];
254        let nonce = [6u8; NONCE_LEN];
255        let ciphertext = vec![0xCCu8; 100];
256        let fr_standard = build_shadow_frame(84, &salt, &nonce, &ciphertext);
257        let parsed = parse_shadow_frame_wide(&fr_standard);
258        // Either the length field decodes to a huge u32 (rejected
259        // for being > data.len() - WIDE_OVERHEAD) or the plaintext
260        // bytes match by accident — but the salt/nonce slots will
261        // be misaligned and any subsequent crypto step will fail.
262        if let Ok(p) = parsed {
263            // If parse "succeeds", the salt/nonce slots are shifted
264            // by 2 bytes, so they don't match the original salt/nonce.
265            // The point of the test is that you can't mix the two.
266            assert_ne!(p.salt, salt);
267        }
268    }
269
270    #[test]
271    fn fdl_capacity_arithmetic() {
272        assert_eq!(compute_max_shadow_fdl(0, 4), 0);
273        assert_eq!(compute_max_shadow_fdl(255, 4), 251);
274        assert_eq!(compute_max_shadow_fdl(510, 4), 502);
275        // remainder shorter than parity: rounds down to full blocks only.
276        assert_eq!(compute_max_shadow_fdl(258, 4), 251);
277    }
278}