Skip to main content

phasm_core/stego/
frame.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Payload frame construction and parsing.
6//!
7//! The frame is the binary container that wraps the encrypted message before
8//! embedding into DCT coefficients. Both Ghost and Armor modes use the same
9//! frame format.
10//!
11//! ## v1 frame (plaintext ≤ 65,535 bytes)
12//!
13//! ```text
14//! [2 bytes ] plaintext length (big-endian u16)
15//! [16 bytes] Argon2 salt (for Tier-2 key derivation)
16//! [12 bytes] AES-GCM-SIV nonce
17//! [N bytes ] ciphertext (plaintext_len + 16 bytes for auth tag)
18//! [4 bytes ] CRC-32 of everything above
19//! ```
20//!
21//! Total frame size = 50 + plaintext_len bytes.
22//!
23//! ## v2 frame (plaintext > 65,535 bytes)
24//!
25//! When the payload exceeds the u16 range, a zero sentinel signals the
26//! extended format. Old decoders see length=0, fail gracefully on CRC/decrypt.
27//!
28//! ```text
29//! [2 bytes ] 0x0000 sentinel
30//! [4 bytes ] plaintext length (big-endian u32)
31//! [16 bytes] Argon2 salt
32//! [12 bytes] AES-GCM-SIV nonce
33//! [N bytes ] ciphertext (plaintext_len + 16 bytes for auth tag)
34//! [4 bytes ] CRC-32 of everything above
35//! ```
36//!
37//! Total frame size = 54 + plaintext_len bytes.
38//!
39//! Note: The mode byte was removed from the frame format to eliminate known
40//! plaintext and improve stealth. Mode detection is handled by trial decoding
41//! in `smart_decode`.
42
43use crate::stego::crypto::{NONCE_LEN, SALT_LEN};
44use crate::stego::error::StegoError;
45
46/// Ghost mode identifier byte.
47pub const MODE_GHOST: u8 = 0x01;
48/// Armor mode identifier byte.
49pub const MODE_ARMOR: u8 = 0x02;
50
51/// v1 fixed overhead: length(2) + salt(16) + nonce(12) + tag(16) + crc(4) = 50 bytes.
52/// Plus the ciphertext length equals plaintext length (AES-GCM stream cipher).
53/// So total v1 frame = 34 + plaintext_len + 16(tag) = 50 + plaintext_len.
54pub const FRAME_OVERHEAD: usize = 2 + SALT_LEN + NONCE_LEN + 16 + 4; // 50
55
56/// v2 extended overhead: sentinel(2) + length(4) + salt(16) + nonce(12) + tag(16) + crc(4) = 54.
57/// Used when plaintext exceeds 65,535 bytes (u16 range).
58pub const FRAME_OVERHEAD_EXT: usize = 2 + 4 + SALT_LEN + NONCE_LEN + 16 + 4; // 54
59
60/// Maximum payload frame size in bytes.
61/// This caps STC allocation: the Viterbi back_ptr grows with m_max (in bits).
62/// 256 KB supports payloads up to ~256 KB, far beyond typical stego use (<1 KB).
63/// Kept modest to limit STC memory on large images (back_ptr ≈ n_used * 512 bytes).
64pub const MAX_FRAME_BYTES: usize = 256 * 1024; // 256 KB
65
66/// Maximum payload frame size in bits.
67pub const MAX_FRAME_BITS: usize = MAX_FRAME_BYTES * 8;
68
69/// Build a payload frame from encrypted components.
70///
71/// Uses v1 format (2-byte u16 length) when `plaintext_len` fits in u16,
72/// otherwise v2 format (0x0000 sentinel + 4-byte u32 length).
73pub fn build_frame(
74    plaintext_len: usize,
75    salt: &[u8; SALT_LEN],
76    nonce: &[u8; NONCE_LEN],
77    ciphertext: &[u8],
78) -> Vec<u8> {
79    debug_assert_eq!(ciphertext.len(), plaintext_len + 16, "ciphertext length mismatch");
80    assert!(plaintext_len <= u32::MAX as usize, "plaintext exceeds u32::MAX");
81
82    let is_v2 = plaintext_len > u16::MAX as usize;
83    let header_len = if is_v2 { 6 } else { 2 };
84    let mut frame = Vec::with_capacity(header_len + SALT_LEN + NONCE_LEN + ciphertext.len() + 4);
85
86    if is_v2 {
87        frame.extend_from_slice(&0u16.to_be_bytes()); // sentinel
88        frame.extend_from_slice(&(plaintext_len as u32).to_be_bytes());
89    } else {
90        frame.extend_from_slice(&(plaintext_len as u16).to_be_bytes());
91    }
92    frame.extend_from_slice(salt);
93    frame.extend_from_slice(nonce);
94    frame.extend_from_slice(ciphertext);
95
96    let crc = crc32fast::hash(&frame);
97    frame.extend_from_slice(&crc.to_be_bytes());
98
99    frame
100}
101
102/// Parsed payload frame.
103///
104/// Contains all fields needed to decrypt the embedded message:
105/// the original plaintext length, the Argon2 salt and AES-GCM-SIV nonce
106/// for key derivation/decryption, and the ciphertext (including auth tag).
107pub struct ParsedFrame {
108    /// Original plaintext length in bytes (before encryption).
109    /// v1 frames store this as u16; v2 frames as u32.
110    pub plaintext_len: u32,
111    /// Argon2 salt for Tier-2 encryption key derivation.
112    pub salt: [u8; SALT_LEN],
113    /// AES-GCM-SIV nonce.
114    pub nonce: [u8; NONCE_LEN],
115    /// Ciphertext including 16-byte authentication tag.
116    pub ciphertext: Vec<u8>,
117}
118
119/// Parse a payload frame, verifying the CRC.
120///
121/// Supports both v1 (u16 length) and v2 (0x0000 sentinel + u32 length) formats.
122/// The input `data` may be larger than the actual frame (e.g. zero-padded).
123///
124/// Returns `Err(StegoError::FrameCorrupted)` if the CRC check fails or the
125/// frame is truncated.
126pub fn parse_frame(data: &[u8]) -> Result<ParsedFrame, StegoError> {
127    if data.len() < 2 {
128        return Err(StegoError::FrameCorrupted);
129    }
130
131    // Detect v1 vs v2 format.
132    let header_u16 = u16::from_be_bytes([data[0], data[1]]);
133    let (plaintext_len, header_len): (usize, usize) = if header_u16 == 0 && data.len() >= 6 {
134        let v2_len = u32::from_be_bytes([data[2], data[3], data[4], data[5]]) as usize;
135        if v2_len > u16::MAX as usize {
136            // v2: sentinel + u32 length
137            (v2_len, 6)
138        } else {
139            // v1 with plaintext_len = 0 (the bytes after are salt, not a u32 length)
140            (0, 2)
141        }
142    } else {
143        (header_u16 as usize, 2)
144    };
145
146    let ciphertext_len = plaintext_len + 16; // AES-GCM-SIV auth tag
147    let total_frame_len = header_len + SALT_LEN + NONCE_LEN + ciphertext_len + 4;
148
149    if total_frame_len > MAX_FRAME_BYTES || data.len() < total_frame_len {
150        return Err(StegoError::FrameCorrupted);
151    }
152
153    // Verify CRC.
154    let payload = &data[..total_frame_len - 4];
155    let crc_bytes = &data[total_frame_len - 4..total_frame_len];
156    let stored_crc = u32::from_be_bytes([crc_bytes[0], crc_bytes[1], crc_bytes[2], crc_bytes[3]]);
157    let computed_crc = crc32fast::hash(payload);
158    if stored_crc != computed_crc {
159        return Err(StegoError::FrameCorrupted);
160    }
161
162    // Parse fields after the length header.
163    let mut salt = [0u8; SALT_LEN];
164    salt.copy_from_slice(&payload[header_len..header_len + SALT_LEN]);
165
166    let mut nonce = [0u8; NONCE_LEN];
167    nonce.copy_from_slice(&payload[header_len + SALT_LEN..header_len + SALT_LEN + NONCE_LEN]);
168
169    let ciphertext = payload[header_len + SALT_LEN + NONCE_LEN..].to_vec();
170
171    Ok(ParsedFrame {
172        plaintext_len: plaintext_len as u32,
173        salt,
174        nonce,
175        ciphertext,
176    })
177}
178
179/// v1 compact frame overhead for Fortress empty-passphrase mode.
180/// Salt and nonce are omitted (derived from constants on both sides).
181/// Layout: length(2) + ciphertext(N+16) + crc(4) = 22 + plaintext_len.
182pub const FORTRESS_COMPACT_FRAME_OVERHEAD: usize = 2 + 16 + 4; // 22
183
184/// v2 compact frame overhead (sentinel + u32 length).
185pub const FORTRESS_COMPACT_FRAME_OVERHEAD_EXT: usize = 2 + 4 + 16 + 4; // 26
186
187/// Build a compact fortress frame (no salt, no nonce embedded).
188///
189/// Uses v1 (u16 length) or v2 (sentinel + u32) based on payload size.
190pub fn build_fortress_compact_frame(
191    plaintext_len: usize,
192    ciphertext: &[u8],
193) -> Vec<u8> {
194    assert!(plaintext_len <= u32::MAX as usize, "plaintext exceeds u32::MAX");
195
196    let is_v2 = plaintext_len > u16::MAX as usize;
197    let header_len = if is_v2 { 6 } else { 2 };
198    let mut frame = Vec::with_capacity(header_len + ciphertext.len() + 4);
199
200    if is_v2 {
201        frame.extend_from_slice(&0u16.to_be_bytes());
202        frame.extend_from_slice(&(plaintext_len as u32).to_be_bytes());
203    } else {
204        frame.extend_from_slice(&(plaintext_len as u16).to_be_bytes());
205    }
206    frame.extend_from_slice(ciphertext);
207
208    let crc = crc32fast::hash(&frame);
209    frame.extend_from_slice(&crc.to_be_bytes());
210
211    frame
212}
213
214/// Parse a compact fortress frame, verifying the CRC.
215///
216/// Supports both v1 and v2 formats. Returns a `ParsedFrame` with `salt` and
217/// `nonce` set to the known Fortress empty-passphrase constants.
218pub fn parse_fortress_compact_frame(data: &[u8]) -> Result<ParsedFrame, StegoError> {
219    use crate::stego::crypto::{FORTRESS_EMPTY_SALT, FORTRESS_EMPTY_NONCE};
220
221    if data.len() < 2 {
222        return Err(StegoError::FrameCorrupted);
223    }
224
225    // Detect v1 vs v2.
226    let header_u16 = u16::from_be_bytes([data[0], data[1]]);
227    let (plaintext_len, header_len): (usize, usize) = if header_u16 == 0 && data.len() >= 6 {
228        let v2_len = u32::from_be_bytes([data[2], data[3], data[4], data[5]]) as usize;
229        if v2_len > u16::MAX as usize {
230            (v2_len, 6)
231        } else {
232            (0, 2)
233        }
234    } else {
235        (header_u16 as usize, 2)
236    };
237
238    let ciphertext_len = plaintext_len + 16;
239    let total_frame_len = header_len + ciphertext_len + 4;
240
241    if total_frame_len > MAX_FRAME_BYTES || data.len() < total_frame_len {
242        return Err(StegoError::FrameCorrupted);
243    }
244
245    let payload = &data[..total_frame_len - 4];
246    let crc_bytes = &data[total_frame_len - 4..total_frame_len];
247    let stored_crc = u32::from_be_bytes([crc_bytes[0], crc_bytes[1], crc_bytes[2], crc_bytes[3]]);
248    let computed_crc = crc32fast::hash(payload);
249    if stored_crc != computed_crc {
250        return Err(StegoError::FrameCorrupted);
251    }
252
253    let ciphertext = payload[header_len..].to_vec();
254
255    Ok(ParsedFrame {
256        plaintext_len: plaintext_len as u32,
257        salt: FORTRESS_EMPTY_SALT,
258        nonce: FORTRESS_EMPTY_NONCE,
259        ciphertext,
260    })
261}
262
263/// Convert bytes to a bit vector (MSB first within each byte).
264pub fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
265    let mut bits = Vec::with_capacity(bytes.len() * 8);
266    for &byte in bytes {
267        for bit_pos in (0..8).rev() {
268            bits.push((byte >> bit_pos) & 1);
269        }
270    }
271    bits
272}
273
274/// Convert a bit vector (MSB first) back to bytes.
275/// Pads the last byte with zero bits if `bits.len()` is not a multiple of 8.
276pub fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
277    let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
278    for chunk in bits.chunks(8) {
279        let mut byte = 0u8;
280        for (i, &bit) in chunk.iter().enumerate() {
281            byte |= (bit & 1) << (7 - i);
282        }
283        bytes.push(byte);
284    }
285    bytes
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn build_parse_roundtrip() {
294        let salt = [1u8; SALT_LEN];
295        let nonce = [2u8; NONCE_LEN];
296        // plaintext_len=2, so ciphertext must be 2+16=18 bytes (AES-GCM tag).
297        let ciphertext = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
298                              0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
299                              0x77, 0x88, 0x99, 0x00, 0xAA, 0xBB];
300        let frame = build_frame(2, &salt, &nonce, &ciphertext);
301        let parsed = parse_frame(&frame).unwrap();
302
303        assert_eq!(parsed.plaintext_len, 2);
304        assert_eq!(parsed.salt, salt);
305        assert_eq!(parsed.nonce, nonce);
306        assert_eq!(parsed.ciphertext, ciphertext);
307    }
308
309    #[test]
310    fn corrupted_crc_detected() {
311        let salt = [0u8; SALT_LEN];
312        let nonce = [0u8; NONCE_LEN];
313        // plaintext_len=4, ciphertext=4+16=20 bytes.
314        let ciphertext = vec![0u8; 20];
315        let mut frame = build_frame(4, &salt, &nonce, &ciphertext);
316        // Corrupt last byte (CRC).
317        let len = frame.len();
318        frame[len - 1] ^= 0xFF;
319        assert!(matches!(parse_frame(&frame), Err(StegoError::FrameCorrupted)));
320    }
321
322    #[test]
323    fn corrupted_length_detected() {
324        let salt = [0u8; SALT_LEN];
325        let nonce = [0u8; NONCE_LEN];
326        // plaintext_len=4, ciphertext=4+16=20 bytes.
327        let ciphertext = vec![0u8; 20];
328        let mut frame = build_frame(4, &salt, &nonce, &ciphertext);
329        // Corrupt the plaintext_len field (byte 0) without updating CRC.
330        frame[0] = 0xFF;
331        assert!(matches!(parse_frame(&frame), Err(StegoError::FrameCorrupted)));
332    }
333
334    #[test]
335    fn bytes_bits_roundtrip() {
336        let original = vec![0xDE, 0xAD, 0xBE, 0xEF];
337        let bits = bytes_to_bits(&original);
338        assert_eq!(bits.len(), 32);
339        let recovered = bits_to_bytes(&bits);
340        assert_eq!(recovered, original);
341    }
342
343    #[test]
344    fn truncated_data_rejected() {
345        // Too short to even read plaintext_len
346        assert!(matches!(parse_frame(&[0x00]), Err(StegoError::FrameCorrupted)));
347        assert!(matches!(parse_frame(&[]), Err(StegoError::FrameCorrupted)));
348    }
349
350    #[test]
351    fn frame_no_mode_byte() {
352        // Verify the frame format has no mode byte -- the frame starts with
353        // plaintext_len (2 bytes), so a frame for plaintext_len=4 should
354        // start with [0x00, 0x04].
355        let salt = [3u8; SALT_LEN];
356        let nonce = [4u8; NONCE_LEN];
357        let ciphertext = vec![0x55u8; 20]; // plaintext_len=4
358        let frame = build_frame(4, &salt, &nonce, &ciphertext);
359
360        // First two bytes are plaintext_len in big-endian
361        assert_eq!(frame[0], 0x00);
362        assert_eq!(frame[1], 0x04);
363
364        // Total size: 2 + 16 + 12 + 20 + 4 = 54
365        assert_eq!(frame.len(), 2 + SALT_LEN + NONCE_LEN + 20 + 4);
366
367        // Roundtrip should work
368        let parsed = parse_frame(&frame).unwrap();
369        assert_eq!(parsed.plaintext_len, 4);
370        assert_eq!(parsed.salt, salt);
371        assert_eq!(parsed.nonce, nonce);
372        assert_eq!(parsed.ciphertext, ciphertext);
373    }
374
375    #[test]
376    fn frame_with_zero_length_data() {
377        let salt = [0u8; SALT_LEN];
378        let nonce = [0u8; NONCE_LEN];
379        // plaintext_len=0, ciphertext=0+16=16 bytes (auth tag only)
380        let ciphertext = vec![0u8; 16];
381        let frame = build_frame(0, &salt, &nonce, &ciphertext);
382        let parsed = parse_frame(&frame).unwrap();
383        assert_eq!(parsed.plaintext_len, 0);
384        assert_eq!(parsed.ciphertext.len(), 16);
385    }
386
387    #[test]
388    fn bits_to_bytes_partial_byte() {
389        // 5 bits should produce 1 byte, padded with zeros
390        let bits = vec![1u8, 0, 1, 1, 0];
391        let bytes = bits_to_bytes(&bits);
392        assert_eq!(bytes.len(), 1);
393        // 10110_000 = 0xB0
394        assert_eq!(bytes[0], 0xB0);
395    }
396
397    // --- Compact fortress frame tests ---
398
399    #[test]
400    fn compact_frame_build_parse_roundtrip() {
401        // plaintext_len=4, ciphertext=4+16=20 bytes (AES-GCM tag).
402        let ciphertext = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
403                              0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
404                              0x77, 0x88, 0x99, 0x00, 0xAA, 0xBB,
405                              0xCC, 0xDD];
406        let frame = build_fortress_compact_frame(4, &ciphertext);
407
408        // Total size: 2 + 20 + 4 = 26
409        assert_eq!(frame.len(), 2 + 20 + 4);
410
411        let parsed = parse_fortress_compact_frame(&frame).unwrap();
412        assert_eq!(parsed.plaintext_len, 4);
413        assert_eq!(parsed.ciphertext, ciphertext);
414        // Salt and nonce should be the fixed constants
415        assert_eq!(parsed.salt, crate::stego::crypto::FORTRESS_EMPTY_SALT);
416        assert_eq!(parsed.nonce, crate::stego::crypto::FORTRESS_EMPTY_NONCE);
417    }
418
419    #[test]
420    fn compact_frame_smaller_than_full() {
421        // Same plaintext_len=4, ciphertext=20 bytes
422        let salt = [1u8; SALT_LEN];
423        let nonce = [2u8; NONCE_LEN];
424        let ciphertext = vec![0u8; 20];
425
426        let full_frame = build_frame(4, &salt, &nonce, &ciphertext);
427        let compact_frame = build_fortress_compact_frame(4, &ciphertext);
428
429        // Full: 2 + 16 + 12 + 20 + 4 = 54
430        // Compact: 2 + 20 + 4 = 26
431        assert_eq!(full_frame.len() - compact_frame.len(), SALT_LEN + NONCE_LEN);
432        assert_eq!(full_frame.len() - compact_frame.len(), 28);
433    }
434
435    #[test]
436    fn compact_frame_corrupted_crc_detected() {
437        let ciphertext = vec![0u8; 20];
438        let mut frame = build_fortress_compact_frame(4, &ciphertext);
439        let len = frame.len();
440        frame[len - 1] ^= 0xFF;
441        assert!(matches!(parse_fortress_compact_frame(&frame), Err(StegoError::FrameCorrupted)));
442    }
443
444    #[test]
445    fn compact_frame_truncated_rejected() {
446        assert!(matches!(parse_fortress_compact_frame(&[0x00]), Err(StegoError::FrameCorrupted)));
447        assert!(matches!(parse_fortress_compact_frame(&[]), Err(StegoError::FrameCorrupted)));
448    }
449
450    #[test]
451    fn compact_frame_zero_length() {
452        // plaintext_len=0, ciphertext=0+16=16 bytes (auth tag only)
453        let ciphertext = vec![0u8; 16];
454        let frame = build_fortress_compact_frame(0, &ciphertext);
455        let parsed = parse_fortress_compact_frame(&frame).unwrap();
456        assert_eq!(parsed.plaintext_len, 0);
457        assert_eq!(parsed.ciphertext.len(), 16);
458    }
459
460    #[test]
461    fn compact_frame_overhead_is_28_less() {
462        assert_eq!(
463            FRAME_OVERHEAD - FORTRESS_COMPACT_FRAME_OVERHEAD,
464            28,
465            "Compact frame saves exactly 28 bytes (salt + nonce)"
466        );
467    }
468
469    #[test]
470    fn v2_ext_overhead_is_4_more() {
471        assert_eq!(FRAME_OVERHEAD_EXT - FRAME_OVERHEAD, 4);
472        assert_eq!(FORTRESS_COMPACT_FRAME_OVERHEAD_EXT - FORTRESS_COMPACT_FRAME_OVERHEAD, 4);
473    }
474
475    #[test]
476    fn v2_frame_build_parse_roundtrip() {
477        let salt = [5u8; SALT_LEN];
478        let nonce = [6u8; NONCE_LEN];
479        let plaintext_len = 70_000usize; // exceeds u16::MAX
480        let ciphertext = vec![0xAB; plaintext_len + 16];
481        let frame = build_frame(plaintext_len, &salt, &nonce, &ciphertext);
482
483        // v2: sentinel(2) + u32(4) + salt(16) + nonce(12) + ct + crc(4) = 54 + plaintext
484        assert_eq!(frame.len(), FRAME_OVERHEAD_EXT + plaintext_len);
485
486        // First 2 bytes are the 0x0000 sentinel.
487        assert_eq!(frame[0], 0x00);
488        assert_eq!(frame[1], 0x00);
489        // Bytes 2..6 are the u32 length.
490        assert_eq!(u32::from_be_bytes([frame[2], frame[3], frame[4], frame[5]]), 70_000);
491
492        let parsed = parse_frame(&frame).unwrap();
493        assert_eq!(parsed.plaintext_len, 70_000);
494        assert_eq!(parsed.salt, salt);
495        assert_eq!(parsed.nonce, nonce);
496        assert_eq!(parsed.ciphertext, ciphertext);
497    }
498
499    #[test]
500    fn v1_frame_still_uses_u16_header() {
501        let salt = [7u8; SALT_LEN];
502        let nonce = [8u8; NONCE_LEN];
503        let plaintext_len = 1000usize; // fits in u16
504        let ciphertext = vec![0xCD; plaintext_len + 16];
505        let frame = build_frame(plaintext_len, &salt, &nonce, &ciphertext);
506
507        // v1: 2 + salt + nonce + ct + crc = 50 + plaintext
508        assert_eq!(frame.len(), FRAME_OVERHEAD + plaintext_len);
509
510        // First 2 bytes are u16 length (not sentinel).
511        assert_eq!(u16::from_be_bytes([frame[0], frame[1]]), 1000);
512
513        let parsed = parse_frame(&frame).unwrap();
514        assert_eq!(parsed.plaintext_len, 1000);
515    }
516
517    #[test]
518    fn v2_compact_frame_roundtrip() {
519        let plaintext_len = 70_000usize;
520        let ciphertext = vec![0xEF; plaintext_len + 16];
521        let frame = build_fortress_compact_frame(plaintext_len, &ciphertext);
522
523        assert_eq!(frame.len(), FORTRESS_COMPACT_FRAME_OVERHEAD_EXT + plaintext_len);
524
525        let parsed = parse_fortress_compact_frame(&frame).unwrap();
526        assert_eq!(parsed.plaintext_len, 70_000);
527        assert_eq!(parsed.ciphertext, ciphertext);
528    }
529}