// SPDX-License-Identifier: GPL-3.0-only //! On-disk file format for fcry. //! //! Layout: //! ```text //! magic "fcry" 4 bytes //! version u8 1 //! alg_id u8 1 //! flags u8 1 //! reserved u8 1 (must be 0) //! chunk_size u32 LE 4 (plaintext bytes per chunk) //! kdf_id u8 1 //! kdf_params variable (depends on kdf_id) //! nonce_prefix [u8; 19] 19 (STREAM nonce prefix) //! --- end of header --- //! chunk[0..N] each chunk_size + 16 bytes, //! last may be shorter //! ``` //! //! The full encoded header is fed as AAD to every chunk, so any tampering //! with chunk_size, nonce_prefix, kdf params, etc. causes authentication //! failure on every chunk. use std::io::Read; use crate::error::FcryError; const MAGIC: [u8; 4] = *b"fcry"; const VERSION: u8 = 1; pub const NONCE_PREFIX_LEN: usize = 19; pub const TAG_LEN: usize = 16; #[repr(u8)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum AlgId { XChaCha20Poly1305 = 1, } impl AlgId { fn from_u8(v: u8) -> Result { match v { 1 => Ok(Self::XChaCha20Poly1305), _ => Err(FcryError::Format(format!("unknown alg id: {v}"))), } } } /// Key-derivation parameters stored in the header. /// /// `Raw` means the key was supplied directly (no KDF). Future variants /// (e.g. Argon2id) will carry their salt + cost parameters here. #[derive(Clone, Debug)] pub enum KdfParams { Raw, } impl KdfParams { fn id(&self) -> u8 { match self { Self::Raw => 0, } } fn write_into(&self, _out: &mut Vec) { match self { Self::Raw => {} } } fn read_from(id: u8, _r: &mut impl Read) -> Result { match id { 0 => Ok(Self::Raw), _ => Err(FcryError::Format(format!("unknown kdf id: {id}"))), } } } #[derive(Clone, Debug)] pub struct Header { pub alg: AlgId, pub flags: u8, pub chunk_size: u32, pub kdf: KdfParams, pub nonce_prefix: [u8; NONCE_PREFIX_LEN], } impl Header { pub fn encode(&self) -> Vec { let mut out = Vec::with_capacity(64); out.extend_from_slice(&MAGIC); out.push(VERSION); out.push(self.alg as u8); out.push(self.flags); out.push(0); // reserved out.extend_from_slice(&self.chunk_size.to_le_bytes()); out.push(self.kdf.id()); self.kdf.write_into(&mut out); out.extend_from_slice(&self.nonce_prefix); out } pub fn read(r: &mut impl Read) -> Result { let mut magic = [0u8; 4]; r.read_exact(&mut magic)?; if magic != MAGIC { return Err(FcryError::Format("not an fcry file (bad magic)".into())); } let mut fixed = [0u8; 4]; r.read_exact(&mut fixed)?; let [version, alg_id, flags, reserved] = fixed; if version != VERSION { return Err(FcryError::Format(format!("unsupported version: {version}"))); } if reserved != 0 { return Err(FcryError::Format("reserved byte must be zero".into())); } let alg = AlgId::from_u8(alg_id)?; let mut chunk_size_bytes = [0u8; 4]; r.read_exact(&mut chunk_size_bytes)?; let chunk_size = u32::from_le_bytes(chunk_size_bytes); if chunk_size == 0 { return Err(FcryError::Format("chunk_size must be > 0".into())); } let mut kdf_id = [0u8; 1]; r.read_exact(&mut kdf_id)?; let kdf = KdfParams::read_from(kdf_id[0], r)?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; r.read_exact(&mut nonce_prefix)?; Ok(Self { alg, flags, chunk_size, kdf, nonce_prefix, }) } } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; #[test] fn roundtrip() { let h = Header { alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size: 1024 * 1024, kdf: KdfParams::Raw, nonce_prefix: [7u8; NONCE_PREFIX_LEN], }; let bytes = h.encode(); let mut cur = Cursor::new(&bytes); let parsed = Header::read(&mut cur).unwrap(); assert_eq!(parsed.alg, h.alg); assert_eq!(parsed.flags, h.flags); assert_eq!(parsed.chunk_size, h.chunk_size); assert_eq!(parsed.nonce_prefix, h.nonce_prefix); assert_eq!(cur.position() as usize, bytes.len()); } #[test] fn rejects_bad_magic() { let mut bytes = Header { alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size: 4096, kdf: KdfParams::Raw, nonce_prefix: [0u8; NONCE_PREFIX_LEN], } .encode(); bytes[0] ^= 1; assert!(matches!( Header::read(&mut Cursor::new(&bytes)), Err(FcryError::Format(_)) )); } }