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}