use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; use argon2::{Algorithm, Argon2, Params, Version}; use chacha20poly1305::{ aead::{Aead, KeyInit}, ChaCha20Poly1305, Key, Nonce, }; use rand::RngCore; use serde::{Deserialize, Serialize}; use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair}; /// Magic bytes for encrypted client state files. const STATE_MAGIC: &[u8; 4] = b"QPCE"; const STATE_SALT_LEN: usize = 16; const STATE_NONCE_LEN: usize = 12; #[derive(Serialize, Deserialize)] pub struct StoredState { pub identity_seed: [u8; 32], pub group: Option>, /// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for state created before hybrid was added. #[serde(default)] pub hybrid_key: Option, /// Cached member public keys for group participants. #[serde(default)] pub member_keys: Vec>, } impl StoredState { pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option)> { let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed)); let group = self .group .map(|bytes| bincode::deserialize(&bytes).context("decode group")) .transpose()?; let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; let hybrid = self.hybrid_key.is_some(); let member = GroupMember::new_with_state(identity, key_store, group, hybrid); let hybrid_kp = self .hybrid_key .map(|bytes| HybridKeypair::from_bytes(&bytes).context("decode hybrid key")) .transpose()?; Ok((member, hybrid_kp)) } pub fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result { let group = member .group_ref() .map(|g| bincode::serialize(g).context("serialize group")) .transpose()?; Ok(Self { identity_seed: member.identity_seed(), group, hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()), member_keys: Vec::new(), }) } } /// Argon2id parameters for client state key derivation (auditable; matches argon2 crate defaults). /// - Memory: 19 MiB (m_cost = 19*1024 KiB) /// - Time: 2 iterations /// - Parallelism: 1 lane const ARGON2_STATE_M_COST: u32 = 19 * 1024; const ARGON2_STATE_T_COST: u32 = 2; const ARGON2_STATE_P_COST: u32 = 1; /// Derive a 32-byte key from a password and salt using Argon2id with explicit parameters. fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> { let params = Params::new(ARGON2_STATE_M_COST, ARGON2_STATE_T_COST, ARGON2_STATE_P_COST, Some(32)) .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params); let mut key = [0u8; 32]; argon2 .hash_password_into(password.as_bytes(), salt, &mut key) .map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?; Ok(key) } /// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext. pub fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result> { let mut salt = [0u8; STATE_SALT_LEN]; rand::rngs::OsRng.fill_bytes(&mut salt); let mut nonce_bytes = [0u8; STATE_NONCE_LEN]; rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); let key = zeroize::Zeroizing::new(derive_state_key(password, &salt)?); let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext) .map_err(|e| anyhow::anyhow!("state encryption failed: {e}"))?; let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len()); out.extend_from_slice(STATE_MAGIC); out.extend_from_slice(&salt); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); Ok(out) } /// Decrypt a QPCE-formatted state file. pub fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result> { let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN; anyhow::ensure!( data.len() > header_len, "encrypted state file too short ({} bytes)", data.len() ); let salt = &data[4..4 + STATE_SALT_LEN]; let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len]; let ciphertext = &data[header_len..]; let key = zeroize::Zeroizing::new(derive_state_key(password, salt)?); let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); let nonce = Nonce::from_slice(nonce_bytes); let plaintext = cipher .decrypt(nonce, ciphertext) .map_err(|_| anyhow::anyhow!("state decryption failed (wrong password?)"))?; Ok(plaintext) } /// Returns true if raw bytes begin with the QPCE magic header. pub fn is_encrypted_state(bytes: &[u8]) -> bool { bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC } pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result { if path.exists() { let mut state = load_existing_state(path, password)?; // Generate hybrid keypair if missing (upgrade from older state). if state.hybrid_key.is_none() { state.hybrid_key = Some(HybridKeypair::generate().to_bytes()); write_state(path, &state, password)?; } return Ok(state); } let identity = IdentityKeypair::generate(); let hybrid_kp = HybridKeypair::generate(); let key_store = DiskKeyStore::persistent(keystore_path(path))?; let member = GroupMember::new_with_state(Arc::new(identity), key_store, None, false); let state = StoredState::from_parts(&member, Some(&hybrid_kp))?; write_state(path, &state, password)?; Ok(state) } pub fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result { let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?; if is_encrypted_state(&bytes) { let pw = password .context("state file is encrypted (QPCE); a password is required to decrypt it")?; let plaintext = decrypt_state(pw, &bytes)?; bincode::deserialize(&plaintext).context("decode encrypted state") } else { bincode::deserialize(&bytes).context("decode state") } } pub fn save_state( path: &Path, member: &GroupMember, hybrid_kp: Option<&HybridKeypair>, password: Option<&str>, ) -> anyhow::Result<()> { let state = StoredState::from_parts(member, hybrid_kp)?; write_state(path, &state, password) } pub fn write_state(path: &Path, state: &StoredState, password: Option<&str>) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; } let plaintext = bincode::serialize(state).context("encode state")?; let bytes = if let Some(pw) = password { encrypt_state(pw, &plaintext)? } else { plaintext }; let tmp = path.with_extension("tmp"); std::fs::write(&tmp, bytes).with_context(|| format!("write state temp {tmp:?}"))?; std::fs::rename(&tmp, path).with_context(|| format!("rename state {tmp:?} -> {path:?}"))?; Ok(()) } pub fn decode_identity_key(hex_str: &str) -> anyhow::Result> { let bytes = super::hex::decode(hex_str) .map_err(|e| anyhow::anyhow!(e)) .context("identity key must be hex")?; anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes"); Ok(bytes) } pub fn keystore_path(state_path: &Path) -> PathBuf { let mut path = state_path.to_path_buf(); path.set_extension("ks"); path } pub fn sha256(bytes: &[u8]) -> Vec { use sha2::{Digest, Sha256}; Sha256::digest(bytes).to_vec() } #[cfg(test)] mod tests { use super::*; #[test] fn encrypt_decrypt_roundtrip() { let plaintext = b"test state data"; let password = "test-password"; let encrypted = encrypt_state(password, plaintext).unwrap(); assert!(is_encrypted_state(&encrypted)); let decrypted = decrypt_state(password, &encrypted).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn wrong_password_fails() { let plaintext = b"test state data"; let encrypted = encrypt_state("correct", plaintext).unwrap(); assert!(decrypt_state("wrong", &encrypted).is_err()); } #[test] fn state_encrypt_decrypt_round_trip() { let state = StoredState { identity_seed: [42u8; 32], hybrid_key: None, group: None, member_keys: Vec::new(), }; let password = "test-password"; let plaintext = bincode::serialize(&state).unwrap(); let encrypted = encrypt_state(password, &plaintext).unwrap(); let decrypted = decrypt_state(password, &encrypted).unwrap(); let recovered: StoredState = bincode::deserialize(&decrypted).unwrap(); assert_eq!(recovered.identity_seed, state.identity_seed); assert!(recovered.hybrid_key.is_none()); assert!(recovered.group.is_none()); } #[test] fn state_encrypt_decrypt_with_hybrid_key() { use zeroize::Zeroizing; let state = StoredState { identity_seed: [7u8; 32], hybrid_key: Some(HybridKeypairBytes { x25519_sk: Zeroizing::new([1u8; 32]), mlkem_dk: Zeroizing::new(vec![3u8; 2400]), mlkem_ek: vec![4u8; 1184], }), group: None, member_keys: Vec::new(), }; let password = "another-password"; let plaintext = bincode::serialize(&state).unwrap(); let encrypted = encrypt_state(password, &plaintext).unwrap(); let decrypted = decrypt_state(password, &encrypted).unwrap(); let recovered: StoredState = bincode::deserialize(&decrypted).unwrap(); assert_eq!(recovered.identity_seed, state.identity_seed); assert!(recovered.hybrid_key.is_some()); } #[test] fn state_wrong_password_fails() { let state = StoredState { identity_seed: [99u8; 32], hybrid_key: None, group: None, member_keys: Vec::new(), }; let plaintext = bincode::serialize(&state).unwrap(); let encrypted = encrypt_state("correct", &plaintext).unwrap(); assert!(decrypt_state("wrong", &encrypted).is_err()); } }