diff --git a/src/crypto.rs b/src/crypto.rs index 00a9515..5ac202a 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -6,7 +6,9 @@ use std::io::{BufReader, Read, Seek, SeekFrom, Write}; use std::sync::Arc; use crate::error::*; -use crate::header::{AlgId, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN}; +use crate::header::{ + AlgId, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN, VERSION_CURRENT, +}; use crate::pipeline; use crate::reader::{AheadReader, ReadInfoChunk}; use crate::secrets::{SecretBytes32, SecretVec}; @@ -96,6 +98,7 @@ pub fn encrypt>( 0 }; let header = Header { + version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags, chunk_size, @@ -347,3 +350,123 @@ pub fn decrypt_range>( out.commit()?; Ok(()) } + +#[cfg(test)] +mod tests { + //! Regression tests for cross-version compatibility. The on-disk header + //! is part of the AEAD AAD, so any byte that ends up in `Header::encode()` + //! must match the bytes that were authenticated when the file was + //! written. The v1 test below catches the regression where `encode()` + //! used to hard-code the current version on output. + use super::*; + use crate::header::{Header, KdfParams, NONCE_PREFIX_LEN}; + use std::fs; + use tempfile::TempDir; + + fn write_v1_ciphertext(path: &std::path::Path, key: &SecretBytes32, plaintext: &[u8]) { + // Build a v1 header by hand: same wire format as v2 with flags=0, + // but with version byte = 1. + let nonce_prefix = [0x42u8; NONCE_PREFIX_LEN]; + let header = Header { + version: 1, + alg: AlgId::XChaCha20Poly1305, + flags: 0, + chunk_size: 64, + kdf: KdfParams::Raw, + nonce_prefix, + plaintext_length: None, + }; + let aad = header.encode(); + // First byte after MAGIC is the version — verify our fixture really + // is v1 (so this test fails open if encode() ever reverts). + assert_eq!(aad[4], 1); + + let chunk_sz = header.chunk_size as usize; + let aead = build_aead(key); + + let mut out = Vec::new(); + out.extend_from_slice(&aad); + + let mut counter: u32 = 0; + let mut pos = 0; + while pos < plaintext.len() { + let end = (pos + chunk_sz).min(plaintext.len()); + let last = end == plaintext.len() && (end - pos) < chunk_sz; + let mut buf = plaintext[pos..end].to_vec(); + let nonce = make_nonce(&nonce_prefix, counter, last); + aead.encrypt_in_place(&nonce, &aad, &mut buf).unwrap(); + out.extend_from_slice(&buf); + pos = end; + if last { + break; + } + // If we hit a chunk-boundary EOF we still need a trailing "last" + // empty chunk to authenticate end-of-stream. + counter = bump_counter(counter).unwrap(); + if pos == plaintext.len() { + let mut empty = Vec::new(); + let nonce = make_nonce(&nonce_prefix, counter, true); + aead.encrypt_in_place(&nonce, &aad, &mut empty).unwrap(); + out.extend_from_slice(&empty); + break; + } + } + // Empty plaintext: emit a single empty "last" chunk. + if plaintext.is_empty() { + let mut empty = Vec::new(); + let nonce = make_nonce(&nonce_prefix, 0, true); + aead.encrypt_in_place(&nonce, &aad, &mut empty).unwrap(); + out.extend_from_slice(&empty); + } + fs::write(path, &out).unwrap(); + } + + #[test] + fn decrypts_v1_ciphertext() { + let dir = TempDir::new().unwrap(); + let ct = dir.path().join("v1.bin"); + let rt = dir.path().join("rt.bin"); + + let mut key = SecretBytes32::zeroed(); + key.with_mut_array(|k| k.copy_from_slice(b"0123456789abcdef0123456789abcdef")); + + // Multi-chunk plaintext (chunk_size = 64 in the fixture). + let plain: Vec = (0..200u8).collect(); + write_v1_ciphertext(&ct, &key, &plain); + + decrypt( + Some(ct.to_str().unwrap()), + Some(rt.to_str().unwrap()), + Some(&key), + None, + 1, + ) + .expect("v1 decrypt should succeed"); + let got = fs::read(&rt).unwrap(); + assert_eq!(got, plain); + } + + #[test] + fn decrypts_v1_ciphertext_parallel() { + // Same fixture, but exercising the multi-threaded pipeline. + let dir = TempDir::new().unwrap(); + let ct = dir.path().join("v1.bin"); + let rt = dir.path().join("rt.bin"); + + let mut key = SecretBytes32::zeroed(); + key.with_mut_array(|k| k.copy_from_slice(b"0123456789abcdef0123456789abcdef")); + + let plain: Vec = (0..200u8).collect(); + write_v1_ciphertext(&ct, &key, &plain); + + decrypt( + Some(ct.to_str().unwrap()), + Some(rt.to_str().unwrap()), + Some(&key), + None, + 4, + ) + .expect("v1 parallel decrypt should succeed"); + assert_eq!(fs::read(&rt).unwrap(), plain); + } +} diff --git a/src/header.rs b/src/header.rs index aef90d2..cd9fa06 100644 --- a/src/header.rs +++ b/src/header.rs @@ -34,7 +34,7 @@ use std::io::Read; use crate::error::FcryError; const MAGIC: [u8; 4] = *b"fcry"; -const VERSION_CURRENT: u8 = 2; +pub const VERSION_CURRENT: u8 = 2; const VERSION_MIN: u8 = 1; pub const NONCE_PREFIX_LEN: usize = 19; @@ -131,6 +131,10 @@ impl KdfParams { #[derive(Clone, Debug)] pub struct Header { + /// On-disk format version. Set to `VERSION_CURRENT` for new encrypts; + /// preserved as-read for decrypt so the AAD recomputed on decode matches + /// the bytes that were authenticated when the file was written. + pub version: u8, pub alg: AlgId, pub flags: u8, pub chunk_size: u32, @@ -144,7 +148,7 @@ impl Header { pub fn encode(&self) -> Vec { let mut out = Vec::with_capacity(72); out.extend_from_slice(&MAGIC); - out.push(VERSION_CURRENT); + out.push(self.version); out.push(self.alg as u8); out.push(self.flags); out.push(0); // reserved @@ -210,6 +214,7 @@ impl Header { }; Ok(Self { + version, alg, flags, chunk_size, @@ -228,6 +233,7 @@ mod tests { #[test] fn roundtrip() { let h = Header { + version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size: 1024 * 1024, @@ -238,6 +244,7 @@ mod tests { let bytes = h.encode(); let mut cur = Cursor::new(&bytes); let parsed = Header::read(&mut cur).unwrap(); + assert_eq!(parsed.version, h.version); assert_eq!(parsed.alg, h.alg); assert_eq!(parsed.flags, h.flags); assert_eq!(parsed.chunk_size, h.chunk_size); @@ -249,6 +256,7 @@ mod tests { #[test] fn roundtrip_length_committed() { let h = Header { + version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags: FLAG_LENGTH_COMMITTED, chunk_size: 65536, @@ -267,6 +275,7 @@ mod tests { #[test] fn rejects_bad_magic() { let mut bytes = Header { + version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size: 4096, @@ -285,6 +294,7 @@ mod tests { #[test] fn rejects_unknown_flag_bits() { let mut bytes = Header { + version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size: 4096, @@ -314,8 +324,12 @@ mod tests { bytes.push(0); // kdf id raw bytes.extend_from_slice(&[3u8; NONCE_PREFIX_LEN]); let parsed = Header::read(&mut Cursor::new(&bytes)).unwrap(); + assert_eq!(parsed.version, 1); assert_eq!(parsed.flags, 0); assert_eq!(parsed.chunk_size, 1024); assert_eq!(parsed.plaintext_length, None); + // Re-encoding must reproduce the original v1 bytes exactly so the + // recomputed AAD matches what the file was authenticated with. + assert_eq!(parsed.encode(), bytes); } }