Skip to main content

phasm_core/stego/
crypto.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Cryptographic primitives for payload encryption.
6//!
7//! Implements a two-tier key derivation scheme using Argon2id:
8//!
9//! - **Tier 1 (structural)**: Deterministic key derived from passphrase + fixed
10//!   salt. Produces `perm_seed` (coefficient permutation) and `hhat_seed`
11//!   (STC matrix generation). Both encoder and decoder derive identical keys.
12//!
13//! - **Tier 2 (encryption)**: AES-256-GCM-SIV key derived from passphrase +
14//!   random salt. The random salt is embedded in the payload frame, so the
15//!   decoder recovers it from the extracted data.
16//!
17//! AES-256-GCM-SIV is chosen over AES-256-GCM for its nonce-misuse resistance,
18//! which provides an extra safety margin since the nonce is randomly generated
19//! and embedded alongside the ciphertext.
20
21use aes_gcm_siv::{Aes256GcmSiv, KeyInit, Nonce};
22use aes_gcm_siv::aead::Aead;
23use argon2::Argon2;
24use zeroize::Zeroizing;
25use crate::stego::error::StegoError;
26
27/// Fixed salt for Ghost Tier-1 (structural) key derivation.
28/// This is intentionally fixed so the decoder can reproduce perm_seed/hhat_seed
29/// from the passphrase alone, before extracting the payload.
30const STRUCTURAL_SALT: &[u8; 16] = b"phasm-ghost-v1\0\0";
31
32/// Fixed salt for Armor Tier-1 (structural) key derivation.
33/// Different from Ghost so the same passphrase produces different permutations.
34const ARMOR_STRUCTURAL_SALT: &[u8; 16] = b"phasm-armor-v1\0\0";
35
36/// Fixed salt for DFT template key derivation (Phase 3 geometry resilience).
37/// Independent from structural/armor keys so the template peaks are uncorrelated.
38const TEMPLATE_SALT: &[u8; 16] = b"phasm-tmpl-v1\0\0\0";
39
40/// Fixed salt for Fortress structural key derivation (BA-QIM block permutation).
41/// Independent from Armor STDM keys so the block order is uncorrelated.
42const FORTRESS_STRUCTURAL_SALT: &[u8; 16] = b"phasm-fort-v1\0\0\0";
43
44/// AES-GCM-SIV nonce length in bytes.
45pub const NONCE_LEN: usize = 12;
46/// Argon2 salt length in bytes.
47pub const SALT_LEN: usize = 16;
48
49/// Fixed salt for Shadow layer structural key derivation (repetition coding).
50/// Independent from all other keys so shadow permutations are uncorrelated.
51const SHADOW_STRUCTURAL_SALT: &[u8; 16] = b"phasm-shdw-v1\0\0\0";
52
53/// Fixed salt for H.264 Phase 3c MVD-domain structural key. Independent from
54/// the Ghost structural salt so the MVD-domain permutation + STC matrix do
55/// not correlate with the coefficient-domain ones, keeping the two
56/// cross-domain flip sets statistically uncorrelated.
57const H264_MVD_STRUCTURAL_SALT: &[u8; 16] = b"phasm-h264mvd-v1";
58
59/// Fixed salt for Fortress empty-passphrase optimization.
60/// When passphrase is empty, we use this deterministic salt so it doesn't need
61/// to be embedded in the frame (saving 16 bytes). The message is still
62/// AES-encrypted so the payload looks random for steganalysis resistance.
63/// NOT secret — just a constant to feed into AES key derivation.
64pub const FORTRESS_EMPTY_SALT: [u8; SALT_LEN] = *b"phasm-fe-salt00\0";
65
66/// Fixed nonce for Fortress empty-passphrase optimization.
67/// When passphrase is empty, we use this deterministic nonce so it doesn't
68/// need to be embedded in the frame (saving 12 bytes).
69/// NOT secret — just a constant to feed into AES-GCM-SIV.
70pub const FORTRESS_EMPTY_NONCE: [u8; NONCE_LEN] = *b"ph-fe-nonce\0";
71
72/// Derive the structural key (Tier 1) from a passphrase.
73///
74/// Returns a 64-byte buffer: first 32 bytes = perm_seed, last 32 bytes = hhat_seed.
75/// This key is deterministic given the passphrase so both encoder and decoder agree.
76pub fn derive_structural_key(passphrase: &str) -> Result<Zeroizing<[u8; 64]>, StegoError> {
77    let mut output = Zeroizing::new([0u8; 64]);
78    Argon2::default()
79        .hash_password_into(passphrase.as_bytes(), STRUCTURAL_SALT, &mut *output)
80        .map_err(|_| StegoError::KeyDerivationFailed)?;
81    Ok(output)
82}
83
84/// Derive the H.264 Phase 3c MVD-domain structural key.
85///
86/// Independent from `derive_structural_key` so Phase 3c's two cross-domain
87/// STC runs use uncorrelated permutations and HHat matrices. Same 64-byte
88/// layout as the main structural key: first 32 = perm_seed, last 32 =
89/// hhat_seed.
90pub fn derive_h264_mvd_structural_key(
91    passphrase: &str,
92) -> Result<Zeroizing<[u8; 64]>, StegoError> {
93    let mut output = Zeroizing::new([0u8; 64]);
94    Argon2::default()
95        .hash_password_into(passphrase.as_bytes(), H264_MVD_STRUCTURAL_SALT, &mut *output)
96        .map_err(|_| StegoError::KeyDerivationFailed)?;
97    Ok(output)
98}
99
100/// H.264 Phase I.0.5: derive a per-GOP seed by mixing a master 32-byte seed
101/// with `gop_idx` and a domain label using SHA-256.
102///
103/// The master seed is already passphrase-derived via `derive_structural_key`
104/// or `derive_h264_mvd_structural_key` (each Argon2-expensive but only run
105/// once per encode/decode), so this per-GOP derivation can be a fast SHA-256
106/// pass — the key material is already secret. Deterministic: same master +
107/// `gop_idx` + label → same output across iOS / Android / x86_64 / WASM.
108///
109/// `label` should be a short distinguishing tag (e.g. `b"coeff-perm"`,
110/// `b"coeff-hhat"`) so the four per-GOP seeds (perm + hhat × coeff + MVD)
111/// are mutually uncorrelated even when they share a master.
112pub fn derive_per_gop_seed_from_master(
113    master_seed: &[u8; 32],
114    gop_idx: u32,
115    label: &[u8],
116) -> [u8; 32] {
117    use sha2::{Digest, Sha256};
118    let mut hasher = Sha256::new();
119    hasher.update(b"phasm-h264-gop-v1");
120    hasher.update(master_seed);
121    hasher.update(label);
122    hasher.update(gop_idx.to_le_bytes());
123    let digest = hasher.finalize();
124    let mut out = [0u8; 32];
125    out.copy_from_slice(&digest);
126    out
127}
128
129/// Derive the Armor structural key (Tier 1) from a passphrase.
130///
131/// Same structure as Ghost but uses a different salt so the same passphrase
132/// produces different permutation/spreading seeds.
133pub fn derive_armor_structural_key(passphrase: &str) -> Result<Zeroizing<[u8; 64]>, StegoError> {
134    let mut output = Zeroizing::new([0u8; 64]);
135    Argon2::default()
136        .hash_password_into(passphrase.as_bytes(), ARMOR_STRUCTURAL_SALT, &mut *output)
137        .map_err(|_| StegoError::KeyDerivationFailed)?;
138    Ok(output)
139}
140
141/// Derive the DFT template key from a passphrase.
142///
143/// Returns a 32-byte key used as a ChaCha20 seed for generating template
144/// peak positions. Independent from Ghost/Armor structural keys.
145pub fn derive_template_key(passphrase: &str) -> Result<[u8; 32], StegoError> {
146    let mut output = [0u8; 32];
147    Argon2::default()
148        .hash_password_into(passphrase.as_bytes(), TEMPLATE_SALT, &mut output)
149        .map_err(|_| StegoError::KeyDerivationFailed)?;
150    Ok(output)
151}
152
153/// Derive the Fortress structural key from a passphrase.
154///
155/// Returns a 32-byte key used as a ChaCha20 seed for generating block
156/// permutation. Independent from Ghost/Armor/Template structural keys.
157pub fn derive_fortress_structural_key(passphrase: &str) -> Result<[u8; 32], StegoError> {
158    let mut output = [0u8; 32];
159    Argon2::default()
160        .hash_password_into(passphrase.as_bytes(), FORTRESS_STRUCTURAL_SALT, &mut output)
161        .map_err(|_| StegoError::KeyDerivationFailed)?;
162    Ok(output)
163}
164
165/// Derive the Shadow structural key from a passphrase.
166///
167/// Returns a 32-byte key used as a ChaCha20 seed for generating position
168/// permutation for shadow layer repetition coding. Independent from all
169/// other structural keys. Wrapped in `Zeroizing` to prevent key material
170/// from lingering in memory after use.
171pub fn derive_shadow_structural_key(passphrase: &str) -> Result<Zeroizing<[u8; 32]>, StegoError> {
172    let mut output = Zeroizing::new([0u8; 32]);
173    Argon2::default()
174        .hash_password_into(passphrase.as_bytes(), SHADOW_STRUCTURAL_SALT, &mut *output)
175        .map_err(|_| StegoError::KeyDerivationFailed)?;
176    Ok(output)
177}
178
179/// Derive the AES-256 encryption key (Tier 2) from passphrase + random salt.
180pub fn derive_encryption_key(passphrase: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, StegoError> {
181    let mut key = Zeroizing::new([0u8; 32]);
182    Argon2::default()
183        .hash_password_into(passphrase.as_bytes(), salt, &mut *key)
184        .map_err(|_| StegoError::KeyDerivationFailed)?;
185    Ok(key)
186}
187
188/// Encrypt plaintext with AES-256-GCM-SIV.
189///
190/// Returns (ciphertext_with_tag, nonce, salt).
191/// The ciphertext includes the 16-byte authentication tag appended by AES-GCM-SIV.
192pub fn encrypt(plaintext: &[u8], passphrase: &str) -> Result<(Vec<u8>, [u8; NONCE_LEN], [u8; SALT_LEN]), StegoError> {
193    use rand::RngCore;
194    let mut rng = rand::thread_rng();
195
196    let mut salt = [0u8; SALT_LEN];
197    rng.fill_bytes(&mut salt);
198
199    let mut nonce_bytes = [0u8; NONCE_LEN];
200    rng.fill_bytes(&mut nonce_bytes);
201
202    let key = derive_encryption_key(passphrase, &salt)?;
203    let cipher = Aes256GcmSiv::new_from_slice(&*key).expect("valid key length");
204    let nonce = Nonce::from_slice(&nonce_bytes);
205
206    let ciphertext = cipher.encrypt(nonce, plaintext).expect("AES-GCM-SIV encrypt should not fail");
207
208    Ok((ciphertext, nonce_bytes, salt))
209}
210
211/// Encrypt plaintext with AES-256-GCM-SIV using caller-provided salt and nonce.
212///
213/// Used by the Fortress compact frame path where salt and nonce are fixed
214/// constants rather than random values.
215pub fn encrypt_with(
216    plaintext: &[u8],
217    passphrase: &str,
218    salt: &[u8; SALT_LEN],
219    nonce_bytes: &[u8; NONCE_LEN],
220) -> Result<Vec<u8>, StegoError> {
221    let key = derive_encryption_key(passphrase, salt)?;
222    let cipher = Aes256GcmSiv::new_from_slice(&*key).expect("valid key length");
223    let nonce = Nonce::from_slice(nonce_bytes);
224
225    Ok(cipher.encrypt(nonce, plaintext).expect("AES-GCM-SIV encrypt should not fail"))
226}
227
228/// Decrypt ciphertext with AES-256-GCM-SIV.
229///
230/// Returns the plaintext or `StegoError::DecryptionFailed` if the passphrase is wrong
231/// or data is corrupted.
232pub fn decrypt(
233    ciphertext: &[u8],
234    passphrase: &str,
235    salt: &[u8],
236    nonce_bytes: &[u8; NONCE_LEN],
237) -> Result<Vec<u8>, StegoError> {
238    let key = derive_encryption_key(passphrase, salt)?;
239    let cipher = Aes256GcmSiv::new_from_slice(&*key).expect("valid key length");
240    let nonce = Nonce::from_slice(nonce_bytes);
241
242    cipher
243        .decrypt(nonce, ciphertext)
244        .map_err(|_| StegoError::DecryptionFailed)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn encrypt_decrypt_roundtrip() {
253        let msg = b"Hello, steganography!";
254        let passphrase = "secret123";
255
256        let (ct, nonce, salt) = encrypt(msg, passphrase).unwrap();
257        let pt = decrypt(&ct, passphrase, &salt, &nonce).unwrap();
258        assert_eq!(pt, msg);
259    }
260
261    #[test]
262    fn wrong_passphrase_fails() {
263        let msg = b"secret message";
264        let (ct, nonce, salt) = encrypt(msg, "correct").unwrap();
265        let result = decrypt(&ct, "wrong", &salt, &nonce);
266        assert!(matches!(result, Err(StegoError::DecryptionFailed)));
267    }
268
269    #[test]
270    fn empty_message_works() {
271        let msg = b"";
272        let passphrase = "pass";
273        let (ct, nonce, salt) = encrypt(msg, passphrase).unwrap();
274        let pt = decrypt(&ct, passphrase, &salt, &nonce).unwrap();
275        assert_eq!(pt, msg.to_vec());
276    }
277
278    #[test]
279    fn structural_key_deterministic() {
280        let a = derive_structural_key("mypass").unwrap();
281        let b = derive_structural_key("mypass").unwrap();
282        assert_eq!(a, b);
283    }
284
285    #[test]
286    fn structural_key_differs_by_passphrase() {
287        let a = derive_structural_key("pass1").unwrap();
288        let b = derive_structural_key("pass2").unwrap();
289        assert_ne!(a, b);
290    }
291
292    #[test]
293    fn ghost_and_armor_structural_keys_differ() {
294        let ghost = derive_structural_key("same_pass").unwrap();
295        let armor = derive_armor_structural_key("same_pass").unwrap();
296        assert_ne!(ghost, armor, "Ghost and Armor keys must differ for the same passphrase");
297    }
298
299    #[test]
300    fn armor_structural_key_deterministic() {
301        let a = derive_armor_structural_key("mypass").unwrap();
302        let b = derive_armor_structural_key("mypass").unwrap();
303        assert_eq!(a, b);
304    }
305
306    #[test]
307    fn template_key_deterministic() {
308        let a = derive_template_key("mypass").unwrap();
309        let b = derive_template_key("mypass").unwrap();
310        assert_eq!(a, b);
311    }
312
313    #[test]
314    fn fortress_key_deterministic() {
315        let a = derive_fortress_structural_key("mypass").unwrap();
316        let b = derive_fortress_structural_key("mypass").unwrap();
317        assert_eq!(a, b);
318    }
319
320    #[test]
321    fn shadow_key_deterministic() {
322        let a = derive_shadow_structural_key("mypass").unwrap();
323        let b = derive_shadow_structural_key("mypass").unwrap();
324        assert_eq!(a, b);
325    }
326
327    #[test]
328    fn shadow_key_differs_from_others() {
329        let ghost = derive_structural_key("same_pass").unwrap();
330        let armor = derive_armor_structural_key("same_pass").unwrap();
331        let fortress = derive_fortress_structural_key("same_pass").unwrap();
332        let template = derive_template_key("same_pass").unwrap();
333        let shadow = derive_shadow_structural_key("same_pass").unwrap();
334        assert_ne!(&ghost[..32], &shadow[..]);
335        assert_ne!(&armor[..32], &shadow[..]);
336        assert_ne!(&fortress[..], &shadow[..]);
337        assert_ne!(&template[..], &shadow[..]);
338    }
339
340    #[test]
341    fn fortress_key_differs_from_others() {
342        let ghost = derive_structural_key("same_pass").unwrap();
343        let armor = derive_armor_structural_key("same_pass").unwrap();
344        let fortress = derive_fortress_structural_key("same_pass").unwrap();
345        let template = derive_template_key("same_pass").unwrap();
346        assert_ne!(&ghost[..32], &fortress[..]);
347        assert_ne!(&armor[..32], &fortress[..]);
348        assert_ne!(&template[..], &fortress[..]);
349    }
350
351    #[test]
352    fn template_key_differs_from_structural() {
353        let ghost = derive_structural_key("same_pass").unwrap();
354        let armor = derive_armor_structural_key("same_pass").unwrap();
355        let template = derive_template_key("same_pass").unwrap();
356        assert_ne!(&ghost[..32], &template[..]);
357        assert_ne!(&armor[..32], &template[..]);
358    }
359
360    #[test]
361    fn encryption_key_differs_by_salt() {
362        let key1 = derive_encryption_key("pass", &[0u8; 16]).unwrap();
363        let key2 = derive_encryption_key("pass", &[1u8; 16]).unwrap();
364        assert_ne!(key1, key2);
365    }
366
367    #[test]
368    fn ciphertext_differs_per_encryption() {
369        // Even with the same plaintext and passphrase, each encryption
370        // should produce different ciphertext (due to random salt + nonce).
371        let msg = b"same message";
372        let (ct1, _, _) = encrypt(msg, "pass").unwrap();
373        let (ct2, _, _) = encrypt(msg, "pass").unwrap();
374        assert_ne!(ct1, ct2, "repeated encryptions should produce different ciphertext");
375    }
376}