diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8553baf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Build Release Binaries + +on: + push: + tags: + - 'v*' # Se lance quand vous créez un tag type "v1.0" + +jobs: + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + name: dspv-windows.exe + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: dspv-linux + - os: macos-latest + target: x86_64-apple-darwin + name: dspv-macos + + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build target + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.name }} + path: target/${{ matrix.target }}/release/Dynamic-Secure-Portable-Volume${{ matrix.os == 'windows-latest' && '.exe' || '' }} diff --git a/Cargo.lock b/Cargo.lock index 7fc595d..f89bf0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ dependencies = [ "aes", "argon2", "bytes", - "ctr", + "dashmap", "dav-server", "fs4", "futures-util", @@ -18,18 +18,19 @@ dependencies = [ "rand", "rpassword", "tokio", + "xts-mode", "zeroize", ] [[package]] name = "aes" -version = "0.9.0" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ + "cfg-if", "cipher", - "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.2.17", ] [[package]] @@ -113,21 +114,18 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -174,37 +172,20 @@ dependencies = [ [[package]] name = "cipher" -version = "0.5.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", + "crypto-common", "inout", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpubits" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -223,6 +204,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -234,21 +221,17 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctr" -version = "0.10.0" +name = "dashmap" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17469f8eb9bdbfad10f71f4cfddfd38b01143520c0e717d8796ccb4d44d44e42" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "cipher", + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -300,8 +283,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] @@ -475,6 +458,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -582,15 +571,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.9.0" @@ -610,7 +590,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", ] [[package]] @@ -619,24 +598,12 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", "tokio", - "tower-layer", - "tower-service", - "tracing", - "windows-registry", ] [[package]] @@ -786,19 +753,13 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "hybrid-array", + "generic-array", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "itoa" version = "1.0.18" @@ -1221,27 +1182,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -1261,7 +1201,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -1293,18 +1232,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -1324,12 +1251,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.19.0" @@ -1389,15 +1310,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1584,17 +1496,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -1831,6 +1732,16 @@ dependencies = [ "xml", ] +[[package]] +name = "xts-mode" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cbddb7545ca0b9ffa7bdc653e8743303e1712687a6918ced25f2cdbed42520" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 27d019d..5d094c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,24 @@ version = "0.1.0" edition = "2024" [dependencies] -aes = "0.9.0" +# --- Cryptographie & Sécurité --- +aes = "0.8.4" +xts-mode = "0.5.1" argon2 = "0.5.3" -bytes = "1.11.1" -ctr = "0.10.0" +rand = "0.10.0" +zeroize = { version = "1.8.2", features = ["zeroize_derive"] } + +# --- Serveur WebDAV & Réseau --- dav-server = "0.11.0" -fs4 = "0.13.1" +hyper = { version = "1.9.0", features = ["server", "http1"] } +hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] } +bytes = "1.11.1" + +# --- Asynchrone & Concurrence --- +tokio = { version = "1.51.1", features = ["rt-multi-thread", "macros", "net", "signal"] } futures-util = "0.3.32" -hyper = { version = "1.9.0", features = ["full"] } -hyper-util = { version = "0.1.20", features = ["full"] } -rand = { version = "0.10.0", features = ["default"] } +dashmap = "6.1.0" + +# --- Utilitaires OS --- +fs4 = "0.13.1" rpassword = "7.4.0" -tokio = { version = "1.51.1", features = ["full"] } -zeroize = { version = "1.8.2", features = ["zeroize_derive"] } diff --git a/secure_volume/cv.txt b/secure_volume/cv.txt new file mode 100644 index 0000000..8843c66 Binary files /dev/null and b/secure_volume/cv.txt differ diff --git a/secure_volume/dspv.meta b/secure_volume/dspv.meta index 244d010..b72cb9f 100644 Binary files a/secure_volume/dspv.meta and b/secure_volume/dspv.meta differ diff --git a/secure_volume/m=0.022 - Copie.png b/secure_volume/m=0.022 - Copie.png new file mode 100644 index 0000000..1305e24 Binary files /dev/null and b/secure_volume/m=0.022 - Copie.png differ diff --git a/secure_volume/m=0.022.png b/secure_volume/m=0.022.png new file mode 100644 index 0000000..1305e24 Binary files /dev/null and b/secure_volume/m=0.022.png differ diff --git a/src/crypto/cipher.rs b/src/crypto/cipher.rs index 21dadc6..159abff 100644 --- a/src/crypto/cipher.rs +++ b/src/crypto/cipher.rs @@ -1,22 +1,25 @@ use crate::utils::memory::SecureKey; +use aes::cipher::KeyInit; use aes::Aes256; -use ctr::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; - -type Aes256Ctr = ctr::Ctr128BE; +use xts_mode::{get_tweak_default, Xts128}; #[derive(Debug)] pub enum CipherError { - //InvalidLength, InitializationFailed, + AlignmentError, } pub trait ChunkCipher { - /// Instancie le chiffreur avec la clé (qui restera en mémoire sécurisée) fn new(key: SecureKey) -> Self; - /// Déchiffre (ou chiffre, car XOR) un bloc de données à un offset donné. - /// L'offset est absolu par rapport au fichier d'origine. - fn process_chunk( + fn encrypt_chunk( + &self, + file_iv: &[u8], + offset: u64, + data: &mut [u8], + ) -> Result<(), CipherError>; + + fn decrypt_chunk( &self, file_iv: &[u8], offset: u64, @@ -24,45 +27,66 @@ pub trait ChunkCipher { ) -> Result<(), CipherError>; } -pub struct Aes256CtrCipher { +pub struct Aes256XtsCipher { key: SecureKey, } -impl ChunkCipher for Aes256CtrCipher { +impl ChunkCipher for Aes256XtsCipher { fn new(key: SecureKey) -> Self { Self { key } } - fn process_chunk( + fn encrypt_chunk( &self, file_iv: &[u8], offset: u64, data: &mut [u8], ) -> Result<(), CipherError> { - if self.key.0.len() != 32 { + if self.key.0.len() != 64 || file_iv.len() != 16 { return Err(CipherError::InitializationFailed); } - if file_iv.len() != 16 { + if !data.len().is_multiple_of(16) { + return Err(CipherError::AlignmentError); + } + + let key1: [u8; 32] = self.key.0[0..32].try_into().unwrap(); + let key2: [u8; 32] = self.key.0[32..64].try_into().unwrap(); + + let cipher_1 = Aes256::new(&key1.into()); + let cipher_2 = Aes256::new(&key2.into()); + let xts = Xts128::::new(cipher_1, cipher_2); + + let sector_index = + ((offset / 16) as u128).wrapping_add(u128::from_le_bytes(file_iv.try_into().unwrap())); + + xts.encrypt_area(data, 16, sector_index, get_tweak_default); + Ok(()) + } + + fn decrypt_chunk( + &self, + file_iv: &[u8], + offset: u64, + data: &mut [u8], + ) -> Result<(), CipherError> { + if self.key.0.len() != 64 || file_iv.len() != 16 { return Err(CipherError::InitializationFailed); } + if !data.len().is_multiple_of(16) { + return Err(CipherError::AlignmentError); + } - let mut cipher = Aes256Ctr::new( - self.key - .0 - .as_slice() - .try_into() - .map_err(|_| CipherError::InitializationFailed)?, - file_iv - .try_into() - .map_err(|_| CipherError::InitializationFailed)?, - ); + let key1: [u8; 32] = self.key.0[0..32].try_into().unwrap(); + let key2: [u8; 32] = self.key.0[32..64].try_into().unwrap(); - // Seek to the absolute offset - cipher.seek(offset); + let cipher_1 = Aes256::new(&key1.into()); + let cipher_2 = Aes256::new(&key2.into()); + let xts = Xts128::::new(cipher_1, cipher_2); - // Encrypt/Decrypt - cipher.apply_keystream(data); + let sector_index = + ((offset / 16) as u128).wrapping_add(u128::from_le_bytes(file_iv.try_into().unwrap())); + xts.decrypt_area(data, 16, sector_index, get_tweak_default); Ok(()) } } @@ -71,41 +95,170 @@ impl ChunkCipher for Aes256CtrCipher { mod tests { use super::*; use crate::utils::memory::SecureKey; - use rand::Rng; + // --- Helper --- + fn dummy_cipher() -> Aes256XtsCipher { + Aes256XtsCipher::new(SecureKey(vec![0x42; 64])) + } + + fn dummy_iv() -> [u8; 16] { + [0xAA; 16] + } + + // ---------------------------------------------------------------- + // TESTS EXISTANTS (Mécanique de base) + // ---------------------------------------------------------------- + + #[test] + fn test_aes256_xts_chunk_basic() { + let cipher = dummy_cipher(); + let iv = dummy_iv(); + + let original_data = b"Hello world!1234This is 32 bytes"; + let mut data = original_data.to_vec(); + + cipher.encrypt_chunk(&iv, 0, &mut data).unwrap(); + assert_ne!(original_data.as_slice(), data.as_slice()); + + cipher.decrypt_chunk(&iv, 0, &mut data).unwrap(); + assert_eq!(original_data.as_slice(), data.as_slice()); + } + + #[test] + fn test_cipher_initialization_error() { + let cipher = Aes256XtsCipher::new(SecureKey(vec![0x42; 32])); // Clé trop courte + let mut data = vec![0u8; 16]; + + let result = cipher.encrypt_chunk(&dummy_iv(), 0, &mut data); + assert!(matches!(result, Err(CipherError::InitializationFailed))); + } + + #[test] + fn test_cipher_alignment_error() { + let cipher = dummy_cipher(); + let mut unaligned_data = vec![0u8; 15]; // Non multiple de 16 + + let result = cipher.encrypt_chunk(&dummy_iv(), 0, &mut unaligned_data); + assert!(matches!(result, Err(CipherError::AlignmentError))); + } + + // ---------------------------------------------------------------- + // TESTS CRITIQUES (Propriétés Cryptographiques) + // ---------------------------------------------------------------- + + /// TEST 1 : L'empreinte doit changer selon la position (Offset/Tweak) + #[test] + fn test_cipher_offset_dependence() { + let cipher = dummy_cipher(); + let iv = dummy_iv(); + let payload = [b'A'; 16]; + + let mut data_at_offset_0 = payload; + cipher.encrypt_chunk(&iv, 0, &mut data_at_offset_0).unwrap(); + + let mut data_at_offset_16 = payload; + cipher + .encrypt_chunk(&iv, 16, &mut data_at_offset_16) + .unwrap(); + + assert_ne!( + data_at_offset_0, data_at_offset_16, + "CRITICAL: L'AES-XTS a produit le même chiffré pour deux offsets différents. Le calcul du sector_index est défaillant." + ); + } + + /// TEST 2 : L'empreinte doit changer selon l'IV (Renouvellement) #[test] - fn test_aes256_ctr_chunk() { - let mut key_bytes = [0u8; 32]; - let mut iv = [0u8; 16]; - rand::rng().fill_bytes(&mut key_bytes); - rand::rng().fill_bytes(&mut iv); + fn test_cipher_iv_dependence() { + let cipher = dummy_cipher(); + let payload = [b'B'; 16]; - let cipher = super::Aes256CtrCipher::new(SecureKey(key_bytes.to_vec())); + let iv1 = [0x11; 16]; + let mut data_iv1 = payload; + cipher.encrypt_chunk(&iv1, 0, &mut data_iv1).unwrap(); - let original_data = - b"Hello world, this is a test string that will be encrypted and decrypted by chunks."; - let mut data_to_encrypt = original_data.to_vec(); + let iv2 = [0x22; 16]; + let mut data_iv2 = payload; + cipher.encrypt_chunk(&iv2, 0, &mut data_iv2).unwrap(); - // Encrypt whole at offset 0 - cipher.process_chunk(&iv, 0, &mut data_to_encrypt).unwrap(); + assert_ne!( + data_iv1, data_iv2, + "CRITICAL: Le changement d'IV n'a pas modifié la signature cryptographique." + ); + } - assert_ne!(original_data.as_slice(), data_to_encrypt.as_slice()); + /// TEST 3 : Cohérence entre le chiffrement global et fragmenté (Streaming) + #[test] + fn test_cipher_cross_chunk_equivalence() { + let cipher = dummy_cipher(); + let iv = dummy_iv(); + let payload = [b'C'; 32]; // 2 blocs stricts - // Decrypt whole at offset 0 - let mut data_to_decrypt = data_to_encrypt.clone(); - cipher.process_chunk(&iv, 0, &mut data_to_decrypt).unwrap(); - assert_eq!(original_data.as_slice(), data_to_decrypt.as_slice()); + // Chiffrement d'un seul bloc de 32 octets + let mut monolithic_data = payload; + cipher.encrypt_chunk(&iv, 0, &mut monolithic_data).unwrap(); - // Decrypt a specific chunk - let offset: u64 = 13; - let chunk_len = 4; - let mut chunk = data_to_encrypt[offset as usize..(offset as usize + chunk_len)].to_vec(); + // Chiffrement fragmenté : deux appels de 16 octets + let mut fragmented_data = payload; + let (part1, part2) = fragmented_data.split_at_mut(16); - cipher.process_chunk(&iv, offset, &mut chunk).unwrap(); + cipher.encrypt_chunk(&iv, 0, part1).unwrap(); + cipher.encrypt_chunk(&iv, 16, part2).unwrap(); assert_eq!( - &original_data[offset as usize..(offset as usize + chunk_len)], - chunk.as_slice() + monolithic_data, fragmented_data, + "CRITICAL: Le chiffrement en flux (chunking) casse la structure XTS. Les blocs ne s'alignent pas correctement." + ); + } + + /// TEST 4 : Isolation des secteurs et Effet Avalanche (Bit-flipping attack) + #[test] + fn test_cipher_sector_isolation_and_avalanche() { + let cipher = dummy_cipher(); + let iv = dummy_iv(); + + let original_data = [b'D'; 32]; + let mut data = original_data; + + // 1. On chiffre les 32 octets + cipher.encrypt_chunk(&iv, 0, &mut data).unwrap(); + + // 2. L'attaquant corrompt un seul bit dans le premier secteur (offset 5) + data[5] ^= 0b0000_0001; + + // 3. On déchiffre le tout + cipher.decrypt_chunk(&iv, 0, &mut data).unwrap(); + + // 4. Vérification de l'effet Avalanche : Le premier secteur (0-15) doit être totalement détruit + assert_ne!( + &data[0..16], + &original_data[0..16], + "CRITICAL: L'effet avalanche n'a pas eu lieu sur le secteur corrompu." + ); + + // 5. Vérification de l'isolation : Le second secteur (16-31) doit être intact + assert_eq!( + &data[16..32], + &original_data[16..32], + "CRITICAL: La corruption d'un secteur a débordé sur le secteur adjacent. L'isolation XTS est brisée." + ); + } + + /// TEST 5 : Résistance aux limites extrêmes (Integer Overflow) + #[test] + fn test_cipher_extreme_offset_wrapping() { + let cipher = dummy_cipher(); + let iv = [0xFF; 16]; // Pousse l'addition interne du `sector_index` dans ses limites + let mut data = [b'E'; 16]; + + // Un offset immense proche de la limite du type + let extreme_offset = u64::MAX - 15; + + // Le wrapping_add ne doit pas faire paniquer le thread + let result = cipher.encrypt_chunk(&iv, extreme_offset, &mut data); + assert!( + result.is_ok(), + "Le calcul du sector_index a paniqué face à un offset extrême." ); } } diff --git a/src/crypto/kdf.rs b/src/crypto/kdf.rs index fa29e6e..80c70d8 100644 --- a/src/crypto/kdf.rs +++ b/src/crypto/kdf.rs @@ -8,7 +8,6 @@ pub enum KdfError { } pub trait KeyDerivation { - /// Génère une clé sécurisée à partir d'un mot de passe et d'un sel. fn derive_key(password: &str, salt: &[u8]) -> Result; } @@ -16,11 +15,11 @@ pub struct Argon2Kdf; impl KeyDerivation for Argon2Kdf { fn derive_key(password: &str, salt: &[u8]) -> Result { - // Paramètres recommandés (approx) : m=64MB, t=3, p=4 - let params = Params::new(65536, 3, 4, Some(32)).map_err(|_| KdfError::InvalidParameters)?; + let params = Params::new(65536, 3, 4, Some(64)).map_err(|_| KdfError::InvalidParameters)?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - let mut key_m = vec![0u8; 32]; + // 64 octets obligatoires pour AES-256-XTS (Clé 1 + Clé 2) + let mut key_m = vec![0u8; 64]; argon2 .hash_password_into(password.as_bytes(), salt, &mut key_m) .map_err(|_| KdfError::DerivationFailed)?; @@ -33,35 +32,103 @@ impl KeyDerivation for Argon2Kdf { mod tests { use super::*; + // --- Helpers --- + fn get_dummy_salt() -> [u8; 16] { + [0u8; 16] // Sel constant pour tests de reproductibilité + } + + /// TEST 1 : Stabilité et Déterminisme (Vecteur de test) + /// Garantit que les paramètres (64Mo, 3 itérations, 4 threads) ne changent jamais par erreur. #[test] - fn test_derive_key_success() { - let password = "super_secret_password"; - let salt = b"random_salt_1234"; + fn test_kdf_parameters_stability() { + let password = "dspv_test_password"; + let salt = b"static_salt_1234"; // 16 octets - let key1 = Argon2Kdf::derive_key(password, salt).expect("Key derivation failed"); - assert_eq!(key1.0.len(), 32); + let key1 = Argon2Kdf::derive_key(password, salt).unwrap(); + let key2 = Argon2Kdf::derive_key(password, salt).unwrap(); - // Same password and salt should yield the same key - let key2 = Argon2Kdf::derive_key(password, salt).expect("Key derivation failed"); + // 1. Déterminisme + assert_eq!(key1.0, key2.0, "Le KDF n'est pas déterministe !"); + + // 2. Taille de sortie (Crucial pour AES-256-XTS) assert_eq!( - key1.0, key2.0, - "Same password and salt should yield the same key" + key1.0.len(), + 64, + "La clé doit faire exactement 64 octets (2x256 bits)" ); + } - // Different salt should yield a different key - let salt2 = b"different_salt__"; - let key3 = Argon2Kdf::derive_key(password, salt2).expect("Key derivation failed"); - assert_ne!( - key1.0, key3.0, - "Different salt should yield a different key" + /// TEST 2 : Effet Avalanche (Sensibilité extrême) + /// Un changement d'un seul bit doit produire une clé totalement différente. + #[test] + fn test_kdf_avalanche_effect() { + let salt = get_dummy_salt(); + let pwd1 = "MotDePasse123!"; + let pwd2 = "MotDePasse123?"; // Juste le dernier caractère change + + let key1 = Argon2Kdf::derive_key(pwd1, &salt).unwrap(); + let key2 = Argon2Kdf::derive_key(pwd2, &salt).unwrap(); + + assert_ne!(key1.0, key2.0); + + // Optionnel : vérifier qu'on n'a pas juste un octet de différence + let mut diff_count = 0; + for i in 0..64 { + if key1.0[i] != key2.0[i] { + diff_count += 1; + } + } + assert!( + diff_count > 50, + "L'effet avalanche est trop faible (faiblesse cryptographique)" + ); + } + + /// TEST 3 : Gestion des cas limites (Mot de passe vide / long) + #[test] + fn test_kdf_edge_cases_passwords() { + let salt = get_dummy_salt(); + + // Mot de passe vide (doit fonctionner mais produire une clé forte) + let key_empty = Argon2Kdf::derive_key("", &salt); + assert!(key_empty.is_ok()); + assert_eq!(key_empty.unwrap().0.len(), 64); + + // Mot de passe très long (plusieurs Ko) + let long_pwd = "A".repeat(10000); + let key_long = Argon2Kdf::derive_key(&long_pwd, &salt); + assert!(key_long.is_ok()); + } + + /// TEST 4 : Sécurité du Sel (Taille minimale) + /// Argon2id nécessite un sel d'au moins 8 octets. + #[test] + fn test_kdf_salt_security_limits() { + let pwd = "password"; + + // Sel trop court (6 octets) + let short_salt = b"123456"; + let result = Argon2Kdf::derive_key(pwd, short_salt); + + assert!( + result.is_err(), + "Le KDF devrait échouer avec un sel de moins de 8 octets" ); + } + + /// TEST 5 : Non-réutilisation (Unique Salt = Unique Key) + #[test] + fn test_kdf_salt_uniqueness() { + let pwd = "same_password"; + let salt1 = b"salt_number_1"; + let salt2 = b"salt_number_2"; + + let key1 = Argon2Kdf::derive_key(pwd, salt1).unwrap(); + let key2 = Argon2Kdf::derive_key(pwd, salt2).unwrap(); - // Different password should yield a different key - let key4 = - Argon2Kdf::derive_key("different_password", salt).expect("Key derivation failed"); assert_ne!( - key1.0, key4.0, - "Different password should yield a different key" + key1.0, key2.0, + "Deux sels différents doivent produire des clés différentes" ); } } diff --git a/src/main.rs b/src/main.rs index 96718f6..da7731d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod utils; use std::convert::Infallible; use std::env; use std::net::SocketAddr; +use std::sync::Arc; use dav_server::DavHandler; use dav_server::memls::MemLs; @@ -15,8 +16,10 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; +use tokio::signal; use crate::protocol::webdav::WebDavFS; +use crate::storage::cache::FileCache; #[tokio::main] async fn main() -> Result<(), Box> { @@ -24,8 +27,6 @@ async fn main() -> Result<(), Box> { println!(" Dynamic Secure Portable Volume (WebDAV Server) "); println!("====================================================\n"); - // 1. Définir le chemin du dossier physique à protéger - // Pour l'instant, on prend le dossier courant ou un dossier passé en argument let args: Vec = env::args().collect(); let physical_root = if args.len() > 1 { args[1].clone() @@ -33,75 +34,86 @@ async fn main() -> Result<(), Box> { "./secure_volume".to_string() }; - // Création du dossier s'il n'existe pas encore if !std::path::Path::new(&physical_root).exists() { std::fs::create_dir_all(&physical_root)?; println!("[+] Dossier physique créé : {}", physical_root); - } else { - println!("[*] Dossier physique cible : {}", physical_root); } - // 2. Saisie du mot de passe (En texte clair pour le dev, à remplacer par rpassword plus tard) - let password = - rpassword::prompt_password("Veuillez entrer la clé de chiffrement du volume : ")?; + let password = rpassword::prompt_password("Clé de chiffrement du volume : ")?; let password = password.trim(); - println!("[*] Dérivation de la clé cryptographique en cours (Argon2)..."); + println!("[*] Déverrouillage du volume..."); let master_key = match crate::storage::vault::VaultManager::unlock_or_create(&physical_root, password) { Ok(key) => key, Err(e) => { - eprintln!("\n[!] ACCÈS REFUSÉ : {}", e); - eprintln!("[!] Le serveur ne démarrera pas pour protéger vos données."); - std::process::exit(1); // Quitte proprement avec un code d'erreur + eprintln!("\n[!] ERREUR : {}", e); + std::process::exit(1); } }; - println!("[+] Clé générée et sécurisée en RAM."); - - // 3. Initialisation du Virtual File System WebDAV - let dav_fs = WebDavFS::new(&physical_root, master_key); + let file_cache = Arc::new(FileCache::new()); + let dav_fs = WebDavFS::new(&physical_root, master_key, file_cache.clone()); - // 4. Construction du DavHandler avec le Lock System let dav_server = DavHandler::builder() .filesystem(Box::new(dav_fs)) .locksystem(MemLs::new()) .build_handler(); - // 5. Configuration et Démarrage du serveur Hyper 1.0 - // On écoute uniquement sur localhost (127.0.0.1) pour des raisons de sécurité ("Zéro-Admin") let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); let listener = TcpListener::bind(addr).await?; - println!("\n===================================================="); - println!("[SUCCESS] Le serveur WebDAV est en ligne !"); - println!(" Connectez-vous via votre OS sur :"); - println!(" http://127.0.0.1:8080/"); - println!(" (Appuyez sur CTRL+C pour quitter et effacer la RAM)"); - println!("====================================================\n"); + println!("\n[+] Serveur en ligne sur http://127.0.0.1:8080/"); + println!("[*] Appuyez sur CTRL+C pour quitter proprement."); + + // Déclenchement de la connexion réseau (Asynchrone) + tokio::task::spawn(async { + // Pause d'une seconde pour s'assurer que le serveur est prêt + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + println!("[*] Ouverture de la connexion réseau..."); + + if let Err(e) = crate::os::open_connection(8080) { + eprintln!("[!] Note : {}", e); + } + }); - // Boucle asynchrone pour accepter les connexions WebDAV entrantes loop { - let (stream, _client_addr) = listener.accept().await?; - - // Encapsulation compatible avec Hyper 1.0 - let io = TokioIo::new(stream); - let dav_server_clone = dav_server.clone(); - - // On lance chaque connexion dans un thread léger (Task) Tokio - tokio::task::spawn(async move { - let service = service_fn(move |req| { - let dav_server_clone = dav_server_clone.clone(); - async move { - // On délègue totalement le traitement de la requête à dav-server - Ok::<_, Infallible>(dav_server_clone.handle(req).await) + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((stream, _)) => { + let io = TokioIo::new(stream); + let dav_server_clone = dav_server.clone(); + + tokio::task::spawn(async move { + let service = service_fn(move |req| { + let dav_server_clone = dav_server_clone.clone(); + async move { Ok::<_, Infallible>(dav_server_clone.handle(req).await) } + }); + + if let Err(err) = http1::Builder::new().serve_connection(io, service).await { + eprintln!("[!] Erreur connexion : {:?}", err); + } + }); + } + Err(e) => eprintln!("[!] Erreur acceptation : {}", e), } - }); + } + _ = signal::ctrl_c() => { + println!("\n[!] Arrêt demandé."); + + println!("[*] Fermeture de la connexion réseau..."); + let _ = crate::os::close_connection(8080); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - eprintln!("[!] Erreur lors du traitement de la connexion : {:?}", err); + println!("[*] Synchronisation finale des fichiers en cours..."); + file_cache.flush_all(); + + break; } - }); + } } + + println!("[+] Volume déconnecté et RAM purgée."); + Ok(()) } diff --git a/src/os/linux.rs b/src/os/linux.rs index e69de29..1684c40 100644 --- a/src/os/linux.rs +++ b/src/os/linux.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +pub fn open_connection(port: u16) -> Result<(), String> { + let dav_url = format!("dav://127.0.0.1:{}/", port); + + // On tente le montage via gio (GVfs) + let _ = Command::new("gio").args(["mount", &dav_url]).output(); + + // On ouvre l'explorateur de fichiers par défaut (Nautilus, Dolphin, etc.) + Command::new("xdg-open") + .arg(&dav_url) + .spawn() + .map_err(|e| format!("Erreur xdg-open : {}", e))?; + Ok(()) +} + +pub fn close_connection(port: u16) -> Result<(), String> { + let dav_url = format!("dav://127.0.0.1:{}/", port); + let _ = Command::new("gio").args(["mount", "-u", &dav_url]).output(); + Ok(()) +} diff --git a/src/os/macos.rs b/src/os/macos.rs new file mode 100644 index 0000000..fb2db6b --- /dev/null +++ b/src/os/macos.rs @@ -0,0 +1,19 @@ +use std::process::Command; + +pub fn open_connection(port: u16) -> Result<(), String> { + let url = format!("http://127.0.0.1:{}/", port); + // 'open' sur une URL WebDAV monte le volume dans /Volumes/ et l'ouvre + Command::new("open") + .arg(&url) + .spawn() + .map_err(|e| format!("Erreur Open macOS : {}", e))?; + Ok(()) +} + +pub fn close_connection(_port: u16) -> Result<(), String> { + // macOS monte par défaut avec le nom de l'IP + let _ = Command::new("diskutil") + .args(["unmount", "force", "/Volumes/127.0.0.1"]) + .output(); + Ok(()) +} diff --git a/src/os/mod.rs b/src/os/mod.rs index e69de29..79d6137 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -0,0 +1,33 @@ +pub mod linux; +pub mod macos; +pub mod windows; + +/// Ouvre l'explorateur de fichiers sur l'adresse du serveur +pub fn open_connection(port: u16) -> Result<(), String> { + #[cfg(target_os = "windows")] + return windows::open_connection(port); + + #[cfg(target_os = "macos")] + return macos::open_connection(port); + + #[cfg(target_os = "linux")] + return linux::open_connection(port); + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + Err("Système d'exploitation non supporté.".to_string()) +} + +/// Ferme ou purge la connexion réseau +pub fn close_connection(port: u16) -> Result<(), String> { + #[cfg(target_os = "windows")] + return windows::close_connection(port); + + #[cfg(target_os = "macos")] + return macos::close_connection(port); + + #[cfg(target_os = "linux")] + return linux::close_connection(port); + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + Ok(()) +} diff --git a/src/os/windows.rs b/src/os/windows.rs index e69de29..713c76c 100644 --- a/src/os/windows.rs +++ b/src/os/windows.rs @@ -0,0 +1,24 @@ +use std::process::Command; + +pub fn open_connection(port: u16) -> Result<(), String> { + let unc_path = format!(r"\\127.0.0.1@{}\DavWWWRoot", port); + Command::new("explorer") + .arg(&unc_path) + .spawn() + .map_err(|e| format!("Erreur Explorer : {}", e))?; + Ok(()) +} + +pub fn close_connection(port: u16) -> Result<(), String> { + let unc_path = format!(r"\\127.0.0.1@{}\DavWWWRoot", port); + let output = Command::new("net") + .args(["use", &unc_path, "/delete", "/y"]) + .output() + .map_err(|e| format!("Erreur Net Use : {}", e))?; + + if output.status.success() || String::from_utf8_lossy(&output.stderr).contains("2250") { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} diff --git a/src/protocol/webdav.rs b/src/protocol/webdav.rs index 808723d..cadeca8 100644 --- a/src/protocol/webdav.rs +++ b/src/protocol/webdav.rs @@ -1,8 +1,5 @@ -//! Intégration WebDAV pour exposer un volume chiffré via le protocole DAV. -//! Utilise la crate `dav-server` (v0.11.0) et mappe dynamiquement un dossier physique. - use std::fs; -use std::io::{self, SeekFrom}; +use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; @@ -15,15 +12,13 @@ use dav_server::fs::{ use futures_util::future::BoxFuture; use futures_util::stream; -use crate::crypto::cipher::{Aes256CtrCipher, ChunkCipher}; +use crate::crypto::cipher::Aes256XtsCipher; +use crate::crypto::cipher::ChunkCipher; +use crate::storage::cache::FileCache; use crate::storage::chunk_io::EncryptedFile; -use crate::storage::header::HEADER_SIZE; +use crate::storage::header::{HEADER_SIZE, LOGICAL_SIZE_OFFSET}; use crate::utils::memory::SecureKey; -// ------------------------------------------------------------ -// 1. Métadonnées (Fichiers et Dossiers) -// ------------------------------------------------------------ - #[derive(Debug, Clone)] pub struct WebDavMetaData { logical_size: u64, @@ -32,14 +27,25 @@ pub struct WebDavMetaData { } impl WebDavMetaData { - fn from_physical(metadata: fs::Metadata) -> Self { + fn resolve(phys_path: &Path, metadata: fs::Metadata, cache: &FileCache) -> Self { let is_dir = metadata.is_dir(); - let physical_size = metadata.len(); - let logical_size = if is_dir { - 0 - } else { - physical_size.saturating_sub(HEADER_SIZE) - }; + let mut logical_size = 0; + + if !is_dir { + if let Some(cached_file) = cache.get_cached(phys_path) { + if let Ok(guard) = cached_file.lock() { + logical_size = guard.logical_size().unwrap_or(0); + } + } else if metadata.len() >= HEADER_SIZE + && let Ok(mut f) = fs::File::open(phys_path) + && f.seek(io::SeekFrom::Start(LOGICAL_SIZE_OFFSET)).is_ok() + { + let mut buf = [0u8; 8]; + if f.read_exact(&mut buf).is_ok() { + logical_size = u64::from_le_bytes(buf); + } + } + } Self { logical_size, @@ -53,24 +59,17 @@ impl DavMetaData for WebDavMetaData { fn len(&self) -> u64 { self.logical_size } - fn modified(&self) -> FsResult { Ok(self.modified) } - fn is_dir(&self) -> bool { self.is_dir } - fn created(&self) -> FsResult { Ok(self.modified) } } -// ------------------------------------------------------------ -// 2. Entrée de répertoire -// ------------------------------------------------------------ - pub struct WebDavDirEntry { name: String, metadata: WebDavMetaData, @@ -80,19 +79,14 @@ impl DavDirEntry for WebDavDirEntry { fn name(&self) -> Vec { self.name.as_bytes().to_vec() } - fn metadata(&self) -> BoxFuture<'_, FsResult>> { let meta = self.metadata.clone(); Box::pin(async move { Ok(Box::new(meta) as Box) }) } } -// ------------------------------------------------------------ -// 3. Fichier Virtuel Chiffré (Wrapper Stateful) -// ------------------------------------------------------------ - pub struct WebDavFile { - inner: Arc>>, + inner: Arc>>, pos: u64, } @@ -141,16 +135,10 @@ impl DavFile for WebDavFile { Box::pin(async move { let len = data_vec.len() as u64; let result = tokio::task::spawn_blocking(move || { - let mut guard = match inner.lock() { - Ok(guard) => guard, - Err(poisoned) => { - eprintln!( - "[!] Avertissement : Récupération d'un Mutex empoisonné sur un fichier." - ); - poisoned.into_inner() - } - }; - guard.write_chunk(offset, &data_vec) + let mut guard = inner.lock().map_err(|_| FsError::GeneralFailure)?; + guard + .write_chunk(offset, &data_vec) + .map_err(|_| FsError::GeneralFailure) }) .await .map_err(|_| FsError::GeneralFailure)?; @@ -160,10 +148,7 @@ impl DavFile for WebDavFile { self.pos += len; Ok(()) } - Err(e) => { - eprintln!("[!] Erreur I/O physique lors de l'écriture : {}", e); - Err(FsError::GeneralFailure) - } + Err(_) => Err(FsError::GeneralFailure), } }) } @@ -174,7 +159,7 @@ impl DavFile for WebDavFile { Box::pin(async move { let result = tokio::task::spawn_blocking(move || { - let mut guard = inner.lock().unwrap(); + let mut guard = inner.lock().map_err(|_| FsError::GeneralFailure)?; guard.read_chunk(offset, count) }) .await @@ -223,28 +208,37 @@ impl DavFile for WebDavFile { } fn flush(&mut self) -> BoxFuture<'_, FsResult<()>> { - Box::pin(async move { Ok(()) }) + let inner = self.inner.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| FsError::GeneralFailure)?; + guard.flush().map_err(|_| FsError::GeneralFailure) + }) + .await + .map_err(|_| FsError::GeneralFailure)? + }) } } -// ------------------------------------------------------------ -// 4. Système de fichiers WebDAV Dynamique -// ------------------------------------------------------------ - #[derive(Clone)] pub struct WebDavFS { physical_root: PathBuf, master_key: SecureKey, + cache: Arc, } impl WebDavFS { - pub fn new>(physical_root: P, master_key: SecureKey) -> Self { + pub fn new>( + physical_root: P, + master_key: SecureKey, + cache: Arc, + ) -> Self { Self { physical_root: physical_root.as_ref().to_path_buf(), master_key, + cache, } } - fn physical_path(&self, dav_path: &DavPath) -> PathBuf { self.physical_root.join(dav_path.as_rel_ospath()) } @@ -257,34 +251,41 @@ impl DavFileSystem for WebDavFS { options: OpenOptions, ) -> BoxFuture<'a, FsResult>> { let phys_path = self.physical_path(path); + let master_key = self.master_key.clone(); + let cache = self.cache.clone(); Box::pin(async move { if phys_path.is_dir() { return Err(FsError::Forbidden); } - // On ne tronque QUE si l'option truncate est explicitement à true - let should_truncate = options.truncate; let write_access = options.write || options.append || options.truncate; - let master_key = self.master_key.clone(); - - let result: io::Result> = - tokio::task::spawn_blocking(move || { - EncryptedFile::open( - &phys_path, - Aes256CtrCipher::new(master_key), - should_truncate, - write_access, - ) - }) - .await - .map_err(|_| FsError::GeneralFailure)?; + + let result = tokio::task::spawn_blocking(move || { + cache.get_or_open( + &phys_path, + Aes256XtsCipher::new(master_key), + options.truncate, + write_access, + ) + }) + .await + .map_err(|_| FsError::GeneralFailure)?; match result { - Ok(enc_file) => Ok(Box::new(WebDavFile { - inner: Arc::new(Mutex::new(enc_file)), - pos: 0, - }) as Box), + Ok(shared_file) => { + // CORRECTION : Gestion vitale du mode 'append' pour l'OS + let initial_pos = if options.append { + shared_file.lock().unwrap().logical_size().unwrap_or(0) + } else { + 0 + }; + + Ok(Box::new(WebDavFile { + inner: shared_file, + pos: initial_pos, + }) as Box) + } Err(_) => Err(FsError::NotFound), } }) @@ -292,11 +293,13 @@ impl DavFileSystem for WebDavFS { fn metadata<'a>(&'a self, path: &'a DavPath) -> BoxFuture<'a, FsResult>> { let phys_path = self.physical_path(path); + let cache = self.cache.clone(); + Box::pin(async move { - match fs::metadata(&phys_path) { - Ok(meta) => { - Ok(Box::new(WebDavMetaData::from_physical(meta)) as Box) - } + tokio::task::spawn_blocking(move || match fs::metadata(&phys_path) { + // CORRECTION : Appel de resolve avec le cache pour éviter l'I/O inutile + Ok(meta) => Ok(Box::new(WebDavMetaData::resolve(&phys_path, meta, &cache)) + as Box), Err(e) => { if e.kind() == io::ErrorKind::NotFound { Err(FsError::NotFound) @@ -304,7 +307,9 @@ impl DavFileSystem for WebDavFS { Err(FsError::GeneralFailure) } } - } + }) + .await + .map_err(|_| FsError::GeneralFailure)? }) } @@ -319,58 +324,86 @@ impl DavFileSystem for WebDavFS { >, > { let phys_path = self.physical_path(path); + let cache = self.cache.clone(); + Box::pin(async move { - let mut entries: Vec> = Vec::new(); - - let read_dir = match fs::read_dir(&phys_path) { - Ok(dir) => dir, - Err(_) => return Err(FsError::NotFound), - }; - - for entry in read_dir.flatten() { - if let Ok(meta) = entry.metadata() { - let name = entry.file_name().to_string_lossy().to_string(); - if name == "dspv.meta" || name.starts_with('.') { - continue; - } + let entries = tokio::task::spawn_blocking(move || { + let mut results: Vec> = Vec::new(); + let read_dir = match fs::read_dir(&phys_path) { + Ok(dir) => dir, + Err(_) => return Err(FsError::NotFound), + }; - entries.push(Box::new(WebDavDirEntry { - name, - metadata: WebDavMetaData::from_physical(meta), - }) as Box); + for entry in read_dir.flatten() { + if let Ok(meta) = entry.metadata() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == "dspv.meta" || name.starts_with('.') { + continue; + } + + // CORRECTION : Passage du cache à la résolution des métadonnées + results.push(Box::new(WebDavDirEntry { + name, + metadata: WebDavMetaData::resolve(&entry.path(), meta, &cache), + }) as Box); + } } - } + Ok(results) + }) + .await + .map_err(|_| FsError::GeneralFailure)??; let s = stream::iter(entries.into_iter().map(Ok)); - Ok(Box::pin(s) - as std::pin::Pin< - Box>> + Send>, - >) + Ok(Box::pin(s) as _) }) } fn create_dir<'a>(&'a self, path: &'a DavPath) -> BoxFuture<'a, FsResult<()>> { let phys_path = self.physical_path(path); - Box::pin(async move { fs::create_dir(&phys_path).map_err(|_| FsError::GeneralFailure) }) + Box::pin(async move { + tokio::task::spawn_blocking(move || fs::create_dir(&phys_path)) + .await + .map_err(|_| FsError::GeneralFailure)? + .map_err(|_| FsError::GeneralFailure) + }) } fn remove_file<'a>(&'a self, path: &'a DavPath) -> BoxFuture<'a, FsResult<()>> { let phys_path = self.physical_path(path); - Box::pin(async move { fs::remove_file(&phys_path).map_err(|_| FsError::GeneralFailure) }) + let cache = self.cache.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + cache.remove(&phys_path); + fs::remove_file(&phys_path) + }) + .await + .map_err(|_| FsError::GeneralFailure)? + .map_err(|_| FsError::GeneralFailure) + }) } fn remove_dir<'a>(&'a self, path: &'a DavPath) -> BoxFuture<'a, FsResult<()>> { let phys_path = self.physical_path(path); - Box::pin(async move { fs::remove_dir(&phys_path).map_err(|_| FsError::GeneralFailure) }) + Box::pin(async move { + tokio::task::spawn_blocking(move || fs::remove_dir(&phys_path)) + .await + .map_err(|_| FsError::GeneralFailure)? + .map_err(|_| FsError::GeneralFailure) + }) } fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> BoxFuture<'a, FsResult<()>> { let from_path = self.physical_path(from); let to_path = self.physical_path(to); + let cache = self.cache.clone(); Box::pin(async move { tokio::task::spawn_blocking(move || { - fs::rename(from_path, to_path).map_err(|_| FsError::GeneralFailure) + cache.remove(&from_path); + fs::rename(from_path, to_path).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => FsError::NotFound, + _ => FsError::GeneralFailure, + }) }) .await .map_err(|_| FsError::GeneralFailure)? @@ -384,47 +417,33 @@ impl DavFileSystem for WebDavFS { Box::pin(async move { tokio::task::spawn_blocking(move || { - // 1. Instancier les chiffreurs (un pour la lecture, un pour l'écriture) - // Note : On clone la clé sécurisée (SecureKey), ce qui est sûr car elle sera zeroizée au drop. - let cipher_src = Aes256CtrCipher::new(master_key.clone()); - let cipher_dst = Aes256CtrCipher::new(master_key); + let cipher_src = Aes256XtsCipher::new(master_key.clone()); + let cipher_dst = Aes256XtsCipher::new(master_key); - // 2. Ouvrir le fichier source (truncate = false pour lire l'en-tête existant, write_access = false) let mut src_file = EncryptedFile::open(&from_path, cipher_src, false, false) .map_err(|_| FsError::NotFound)?; - - // 3. Récupérer la taille logique pour borner la copie let logical_size = src_file .logical_size() .map_err(|_| FsError::GeneralFailure)?; - // 4. Ouvrir/Créer le fichier de destination - // CRITIQUE : truncate = true force la génération d'un NOUVEAU FileHeader avec un nouvel IV - // write_access = true car on va copier des données dedans let mut dst_file = EncryptedFile::open(&to_path, cipher_dst, true, true) .map_err(|_| FsError::GeneralFailure)?; - // 5. Streaming par blocs (Chunking) pour préserver la RAM - let chunk_size: usize = 64 * 1024; // 64 Ko par itération (compromis idéal vitesse/mémoire) + let chunk_size: usize = 64 * 1024; let mut offset: u64 = 0; while offset < logical_size { let remaining = (logical_size - offset) as usize; let current_chunk_size = remaining.min(chunk_size); - // Lecture et déchiffrement (le résultat atterrit dans un SecureBuffer) let secure_buf = src_file .read_chunk(offset, current_chunk_size) .map_err(|_| FsError::GeneralFailure)?; - - // Chiffrement et écriture avec le nouvel IV dst_file .write_chunk(offset, &secure_buf.0) .map_err(|_| FsError::GeneralFailure)?; - offset += current_chunk_size as u64; } - Ok(()) }) .await @@ -433,27 +452,18 @@ impl DavFileSystem for WebDavFS { } fn get_quota(&self) -> BoxFuture<'_, FsResult<(u64, Option)>> { - // On clone le chemin physique pour pouvoir le déplacer dans le thread bloquant let phys_root = self.physical_root.clone(); - Box::pin(async move { let result = tokio::task::spawn_blocking(move || { - // fs4 interroge l'OS (Windows/macOS/Linux) pour obtenir l'espace réel - // du disque hébergeant le dossier physique. let total_space = fs4::total_space(&phys_root).unwrap_or(0); let available_space = fs4::available_space(&phys_root).unwrap_or(0); - - // Le quota utilisé est calculé par déduction - let used_space = total_space.saturating_sub(available_space); - - (used_space, Some(total_space)) + ( + total_space.saturating_sub(available_space), + Some(total_space), + ) }) .await - .map_err(|e| { - eprintln!("[!] Erreur de thread lors du calcul du quota : {}", e); - FsError::GeneralFailure - })?; - + .map_err(|_| FsError::GeneralFailure)?; Ok(result) }) } @@ -468,93 +478,147 @@ mod tests { use super::*; use dav_server::fs::OpenOptions; use std::fs; - use std::io::Write; // --- Helper --- fn setup_test_env(dir_name: &str) -> (WebDavFS, SecureKey) { let _ = fs::remove_dir_all(dir_name); fs::create_dir_all(dir_name).unwrap(); - let master_key = SecureKey(vec![0x42; 32]); - let dav_fs = WebDavFS::new(dir_name, master_key.clone()); - (dav_fs, master_key) + let master_key = SecureKey(vec![0x42; 64]); + let cache = std::sync::Arc::new(crate::storage::cache::FileCache::new()); + ( + WebDavFS::new(dir_name, master_key.clone(), cache), + master_key, + ) } - // 1. Test des métadonnées (Calcul Taille Logique vs Physique) + /// TEST 1 : Cycle de vie complet d'un fichier (Création, I/O, Append, Truncate, Seek, Comportement Windows) #[tokio::test] - async fn test_metadata_logic() { - let root = "test_meta"; + async fn test_webdav_file_lifecycle_and_io() { + let root = "test_io"; let (dav_fs, _) = setup_test_env(root); - let path = DavPath::new("/file.enc").unwrap(); - - // Création physique d'un fichier avec en-tête + 10 octets - let phys_path = PathBuf::from(root).join("file.enc"); - let mut f = fs::File::create(&phys_path).unwrap(); - f.write_all(&[0u8; (HEADER_SIZE + 10) as usize]).unwrap(); + let path = DavPath::new("/lifecycle.enc").unwrap(); - let meta = dav_fs.metadata(&path).await.unwrap(); - assert_eq!( - meta.len(), - 10, - "La taille logique doit être TaillePhysique - HEADER_SIZE" - ); - assert!(!meta.is_dir()); + // 1. Création et écriture initiale + { + let mut f = dav_fs + .open( + &path, + OpenOptions { + create: true, + write: true, + ..Default::default() + }, + ) + .await + .unwrap(); + f.write_bytes(Bytes::from("Hello")).await.unwrap(); + } - let _ = fs::remove_dir_all(root); - } + // 2. Mode Append (Vérifie que l'offset reprend à la fin sans écraser) + { + let mut f = dav_fs + .open( + &path, + OpenOptions { + append: true, + write: true, + ..Default::default() + }, + ) + .await + .unwrap(); + f.write_bytes(Bytes::from(" World")).await.unwrap(); + } - // 2. Test du Seek (Start, Current, End) - #[tokio::test] - async fn test_file_seek_logic() { - let root = "test_seek"; - let (dav_fs, _) = setup_test_env(root); - let path = DavPath::new("/seek.enc").unwrap(); - - let mut file = dav_fs - .open( - &path, - OpenOptions { - read: true, - write: true, - create: true, - ..Default::default() - }, - ) - .await - .unwrap(); + // 3. Lecture et Seek (Vérification globale + Simulation OS Windows) + { + let mut f = dav_fs + .open( + &path, + OpenOptions { + read: true, + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + f.metadata().await.unwrap().len(), + 11, + "La taille logique doit être de 11 après l'append" + ); - // On écrit 20 octets - file.write_bytes(Bytes::from(vec![0u8; 20])).await.unwrap(); + f.seek(SeekFrom::Start(0)).await.unwrap(); + assert_eq!( + f.read_bytes(11).await.unwrap().as_ref(), + b"Hello World", + "Le contenu déchiffré est erroné" + ); - // Test Seek Start - assert_eq!(file.seek(SeekFrom::Start(5)).await.unwrap(), 5); + // Validation des Seek relatifs et par la fin + assert_eq!(f.seek(SeekFrom::End(-5)).await.unwrap(), 6); + assert_eq!(f.seek(SeekFrom::Current(2)).await.unwrap(), 8); + assert!(f.flush().await.is_ok()); + } - // Test Seek Current - assert_eq!(file.seek(SeekFrom::Current(2)).await.unwrap(), 7); - assert_eq!(file.seek(SeekFrom::Current(-3)).await.unwrap(), 4); + // 4. Mode Truncate (Écrase les anciennes données) + { + let mut f = dav_fs + .open( + &path, + OpenOptions { + truncate: true, + write: true, + ..Default::default() + }, + ) + .await + .unwrap(); + f.write_bytes(Bytes::from("NEW")).await.unwrap(); + } - // Test Seek End - assert_eq!(file.seek(SeekFrom::End(-5)).await.unwrap(), 15); + assert_eq!( + dav_fs.metadata(&path).await.unwrap().len(), + 3, + "Le Truncate n'a pas réinitialisé la taille logique" + ); let _ = fs::remove_dir_all(root); } - // 3. Test du Listage (Filtrage des fichiers cachés) + /// TEST 2 : Manipulation de l'arbre de fichiers (Listage filtré, Renommage, Copie, Suppression) #[tokio::test] - async fn test_fs_list_filtering() { - let root = "test_list"; - let _ = fs::remove_dir_all(root); - fs::create_dir_all(root).unwrap(); + async fn test_webdav_fs_operations() { + let root = "test_fs_ops"; + let (dav_fs, _) = setup_test_env(root); - // Création d'un fichier normal et d'un fichier caché - fs::File::create(PathBuf::from(root).join("visible.enc")).unwrap(); + let p_vis = DavPath::new("/visible.enc").unwrap(); + + // CORRECTION 1 : Création propre via WebDAV pour générer le FileHeader (32 octets) + { + let mut f = dav_fs + .open( + &p_vis, + OpenOptions { + create: true, + write: true, + ..Default::default() + }, + ) + .await + .unwrap(); + f.write_bytes(Bytes::from("test")).await.unwrap(); + } + + // Le fichier caché peut être créé physiquement car on teste juste qu'il est ignoré par read_dir fs::File::create(PathBuf::from(root).join(".hidden")).unwrap(); - let dav_fs = WebDavFS::new(root, SecureKey(vec![0; 32])); + // 2. Filtrage au listage + let root_path = DavPath::new("/").unwrap(); let entries = dav_fs - .read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None) + .read_dir(&root_path, ReadDirMeta::None) .await .unwrap(); - let names: Vec = stream::StreamExt::collect::>(entries) .await .into_iter() @@ -564,131 +628,105 @@ mod tests { assert!(names.contains(&"visible.enc".to_string())); assert!( !names.contains(&".hidden".to_string()), - "Les fichiers commençant par '.' doivent être ignorés" + "Les fichiers cachés doivent être ignorés" ); - let _ = fs::remove_dir_all(root); - } + // 3. Copie et Renommage + let p_copy = DavPath::new("/copy.enc").unwrap(); + let p_rename = DavPath::new("/rename.enc").unwrap(); - // 4. Test Rename (Déplacement physique) - #[tokio::test] - async fn test_fs_rename() { - let root = "test_rename"; - let (dav_fs, _) = setup_test_env(root); - let p1 = DavPath::new("/old.enc").unwrap(); - let p2 = DavPath::new("/new.enc").unwrap(); + dav_fs.copy(&p_vis, &p_copy).await.unwrap(); + assert!( + fs::metadata(PathBuf::from(root).join("copy.enc")).is_ok(), + "Copie échouée" + ); + + dav_fs.rename(&p_copy, &p_rename).await.unwrap(); + assert!( + fs::metadata(PathBuf::from(root).join("copy.enc")).is_err(), + "L'ancien fichier post-renommage existe toujours" + ); + assert!( + fs::metadata(PathBuf::from(root).join("rename.enc")).is_ok(), + "Le nouveau fichier n'existe pas" + ); - fs::File::create(PathBuf::from(root).join("old.enc")).unwrap(); - dav_fs.rename(&p1, &p2).await.unwrap(); + // 4. Suppression + dav_fs.remove_file(&p_vis).await.unwrap(); + dav_fs.remove_file(&p_rename).await.unwrap(); + assert!(fs::metadata(PathBuf::from(root).join("visible.enc")).is_err()); - assert!(fs::metadata(PathBuf::from(root).join("new.enc")).is_ok()); - assert!(fs::metadata(PathBuf::from(root).join("old.enc")).is_err()); + let d_dir = DavPath::new("/dir").unwrap(); + dav_fs.create_dir(&d_dir).await.unwrap(); + dav_fs.remove_dir(&d_dir).await.unwrap(); + assert!(fs::metadata(PathBuf::from(root).join("dir")).is_err()); let _ = fs::remove_dir_all(root); } - // 5. Test Copy (Duplication physique) + /// TEST 3 : Résolution des Métadonnées (Cache RAM vs Header Physique) et Quota OS #[tokio::test] - async fn test_fs_copy() { - let root = "test_copy"; + async fn test_webdav_metadata_cache_and_quota() { + let root = "test_meta_quota"; let (dav_fs, _) = setup_test_env(root); - let p1 = DavPath::new("/src.enc").unwrap(); - let p2 = DavPath::new("/dst.enc").unwrap(); + let path = DavPath::new("/cache_test.enc").unwrap(); + let phys_path = PathBuf::from(root).join("cache_test.enc"); - // Création d'un vrai fichier chiffré via WebDAV pour générer l'en-tête (Header) + // 1. Test du Cache RAM (Fichier verrouillé et non flushé) { - let mut f = dav_fs + let mut file = dav_fs .open( - &p1, + &path, OpenOptions { create: true, - truncate: true, write: true, ..Default::default() }, ) .await .unwrap(); - f.write_bytes(Bytes::from("données de test")).await.unwrap(); - } // Le fichier est fermé (drop) ici - dav_fs.copy(&p1, &p2).await.unwrap(); + file.write_bytes(Bytes::from("Data")).await.unwrap(); - assert!(fs::metadata(PathBuf::from(root).join("src.enc")).is_ok()); - assert!(fs::metadata(PathBuf::from(root).join("dst.enc")).is_ok()); - - let _ = fs::remove_dir_all(root); - } - - // 6. Test de suppression (Fichier et Dossier) - #[tokio::test] - async fn test_fs_deletion() { - let root = "test_del"; - let (dav_fs, _) = setup_test_env(root); + // Le fichier I/O est toujours ouvert, les métadonnées doivent être interceptées depuis le cache RAM + assert_eq!( + dav_fs.metadata(&path).await.unwrap().len(), + 4, + "La résolution n'a pas utilisé le cache RAM" + ); + } // file est drop ici (flush et fermeture OS garantis) - // Dossier - let d = DavPath::new("/dir").unwrap(); - dav_fs.create_dir(&d).await.unwrap(); - dav_fs.remove_dir(&d).await.unwrap(); - assert!(fs::metadata(PathBuf::from(root).join("dir")).is_err()); + // CORRECTION 2 : Éviction explicite du cache RAM pour obliger le système à relire le disque + dav_fs.cache.remove(&phys_path); - // Fichier - let f = DavPath::new("/file.enc").unwrap(); - fs::File::create(PathBuf::from(root).join("file.enc")).unwrap(); - dav_fs.remove_file(&f).await.unwrap(); - assert!(fs::metadata(PathBuf::from(root).join("file.enc")).is_err()); + // 2. Test du Fallback I/O (Émulation d'un fichier existant lu depuis le header physique) + let mut f = fs::OpenOptions::new().write(true).open(&phys_path).unwrap(); - let _ = fs::remove_dir_all(root); - } + // On forge un FileHeader avec une fausse taille logique pour prouver qu'il est bien lu sur le disque + let mut header = crate::storage::header::FileHeader::generate_new(); + header.logical_size = 999; + f.seek(std::io::SeekFrom::Start(0)).unwrap(); + header.write_to(&mut f).unwrap(); + drop(f); // Relâchement explicite du lock OS - #[tokio::test] - async fn test_webdav_windows_behavior_simulation() { - let root = "test_windows_sim"; - let (dav_fs, _) = setup_test_env(root); - - let file_path = DavPath::new("/win_test.txt").unwrap(); - let content = b"Donnees secretes 123"; - - // 1. SIMULATION CRÉATION (Windows fait souvent un open avec truncate) - { - let opts = OpenOptions { - create: true, - truncate: true, - write: true, - ..Default::default() - }; - let mut file = dav_fs.open(&file_path, opts).await.unwrap(); - file.write_bytes(Bytes::copy_from_slice(content)) - .await - .unwrap(); - // Le fichier est DROP ici (fermé) - } + assert_eq!( + dav_fs.metadata(&path).await.unwrap().len(), + 999, + "La taille n'a pas été lue depuis le FileHeader physique" + ); - // 2. SIMULATION LECTURE (Windows rouvre le fichier sans truncate) - { - let opts = OpenOptions { - read: true, - write: false, - ..Default::default() - }; - let mut file = dav_fs.open(&file_path, opts).await.unwrap(); - - // On vérifie d'abord les métadonnées (Windows le fait toujours avant de lire) - let meta = file.metadata().await.unwrap(); - assert_eq!( - meta.len(), - content.len() as u64, - "La taille lue par l'OS est erronée !" - ); + // 3. Test du Quota OS + let quota_result = dav_fs.get_quota().await; + assert!(quota_result.is_ok(), "L'API de quota OS a échoué"); - // Lecture réelle - file.seek(io::SeekFrom::Start(0)).await.unwrap(); - let data = file.read_bytes(content.len()).await.unwrap(); - assert_eq!( - data.as_ref(), - content, - "Le contenu déchiffré est vide ou incorrect !" - ); - } + let (used, total) = quota_result.unwrap(); + assert!( + total.unwrap() > 0, + "La taille totale du disque ne peut pas être de 0" + ); + assert!( + used <= total.unwrap(), + "L'espace utilisé ne peut pas dépasser l'espace total" + ); let _ = fs::remove_dir_all(root); } diff --git a/src/storage/cache.rs b/src/storage/cache.rs index e69de29..98ef4aa 100644 --- a/src/storage/cache.rs +++ b/src/storage/cache.rs @@ -0,0 +1,239 @@ +use dashmap::DashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::crypto::cipher::Aes256XtsCipher; +use crate::storage::chunk_io::EncryptedFile; + +pub struct FileCache { + entries: DashMap>>>, +} + +impl FileCache { + pub fn new() -> Self { + Self { + entries: DashMap::new(), + } + } + + pub fn get_or_open( + &self, + path: &Path, + cipher: Aes256XtsCipher, + truncate: bool, + write_access: bool, + ) -> io::Result>>> { + let path_buf = path.to_path_buf(); + + if truncate { + self.entries.remove(&path_buf); + } + + if let Some(entry) = self.entries.get(&path_buf) { + return Ok(entry.value().clone()); + } + + let file = EncryptedFile::open(path, cipher, truncate, write_access)?; + let shared_file = Arc::new(Mutex::new(file)); + + self.entries.insert(path_buf.clone(), shared_file.clone()); + + Ok(shared_file) + } + + pub fn get_cached(&self, path: &Path) -> Option>>> { + self.entries.get(path).map(|entry| entry.value().clone()) + } + + pub fn remove(&self, path: &Path) { + self.entries.remove(path); + } + + pub fn flush_all(&self) { + for entry in self.entries.iter() { + if let Ok(mut file) = entry.value().lock() { + let _ = file.flush(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Arc; + use std::thread; + + use crate::crypto::cipher::{Aes256XtsCipher, ChunkCipher}; + use crate::utils::memory::SecureKey; + + // --- Helper --- + // Génère un chiffreur factice valide pour les tests I/O + fn dummy_cipher() -> Aes256XtsCipher { + Aes256XtsCipher::new(SecureKey(vec![0x42; 64])) + } + + /// TEST 1 : Vérification de la mécanique de Singleton (Égalité des pointeurs) + /// L'OS peut demander à ouvrir le même fichier 10 fois. Le cache DOIT renvoyer + /// exactement la même instance mémoire pour éviter la corruption (Access Denied). + #[test] + fn test_cache_singleton_behavior() { + let cache = FileCache::new(); + let path = PathBuf::from("test_cache_singleton.enc"); + let _ = fs::remove_file(&path); + + // Première ouverture + let file1 = cache + .get_or_open(&path, dummy_cipher(), true, true) + .expect("Échec ouverture 1"); + + // Deuxième ouverture du MÊME fichier (sans truncate) + let file2 = cache + .get_or_open(&path, dummy_cipher(), false, true) + .expect("Échec ouverture 2"); + + // ASSERTION CRITIQUE : file1 et file2 DOIVENT pointer vers la même adresse mémoire + assert!( + Arc::ptr_eq(&file1, &file2), + "FAIL: Le cache a créé deux instances distinctes pour le même fichier !" + ); + + // Il ne doit y avoir qu'une seule entrée dans le DashMap + assert_eq!(cache.entries.len(), 1); + + let _ = fs::remove_file(&path); + } + + /// TEST 2 : Comportement du mode Truncate (Éviction forcée) + /// Si Windows demande d'écraser un fichier (Truncate), le cache doit détruire + /// l'ancienne référence en RAM et en rouvrir une nouvelle propre. + #[test] + fn test_cache_truncate_forces_eviction() { + let cache = FileCache::new(); + let path = PathBuf::from("test_cache_truncate.enc"); + let _ = fs::remove_file(&path); + + let file_old = cache + .get_or_open(&path, dummy_cipher(), true, true) + .unwrap(); + + // On rouvre le fichier avec `truncate = true` + let file_new = cache + .get_or_open(&path, dummy_cipher(), true, true) + .unwrap(); + + // ASSERTION CRITIQUE : les pointeurs doivent être DIFFÉRENTS cette fois-ci + assert!( + !Arc::ptr_eq(&file_old, &file_new), + "FAIL: Le mode truncate n'a pas évincé l'ancienne instance du cache !" + ); + + let _ = fs::remove_file(&path); + } + + /// TEST 3 : Cycle de vie passif (get_cached) et suppression (remove) + #[test] + fn test_cache_passive_read_and_remove() { + let cache = FileCache::new(); + let path = PathBuf::from("test_cache_lifecycle.enc"); + let _ = fs::remove_file(&path); + + // 1. Avant création, le cache doit renvoyer None sans I/O + assert!(cache.get_cached(&path).is_none()); + + // 2. Création + let _ = cache + .get_or_open(&path, dummy_cipher(), true, true) + .unwrap(); + + // 3. get_cached doit maintenant renvoyer Some (le fichier est en RAM) + assert!( + cache.get_cached(&path).is_some(), + "FAIL: get_cached n'a pas trouvé le fichier fraîchement créé" + ); + + // 4. Suppression explicite + cache.remove(&path); + assert!( + cache.get_cached(&path).is_none(), + "FAIL: remove() n'a pas purgé le fichier du cache" + ); + + let _ = fs::remove_file(&path); + } + + /// TEST 4 : Résistance à la concurrence massive (Race Conditions) + /// Simule un explorateur de fichiers agressif (ex: macOS Finder) qui lance + /// 20 threads simultanés pour lire le même fichier. + #[test] + fn test_cache_heavy_concurrency() { + let cache = Arc::new(FileCache::new()); + let path = Arc::new(PathBuf::from("test_cache_concurrent.enc")); + + // Initialisation propre + let _ = fs::remove_file(path.as_ref()); + let _ = cache + .get_or_open(path.as_ref(), dummy_cipher(), true, true) + .unwrap(); + + let mut handles = vec![]; + + // Lancement de 20 threads qui tentent d'accéder au même fichier + for _ in 0..20 { + let cache_clone = cache.clone(); + let path_clone = path.clone(); + + handles.push(thread::spawn(move || { + cache_clone + .get_or_open(path_clone.as_ref(), dummy_cipher(), false, true) + .unwrap() + })); + } + + // Récupération de tous les pointeurs + let mut resolved_arcs = vec![]; + for handle in handles { + resolved_arcs.push(handle.join().unwrap()); + } + + // ASSERTION CRITIQUE : Les 20 threads doivent partager EXACTEMENT le même pointeur Arc. + // Si DashMap est mal utilisé, cela créerait des doublons. + let reference_arc = &resolved_arcs[0]; + for arc in resolved_arcs.iter().skip(1) { + assert!( + Arc::ptr_eq(reference_arc, arc), + "FAIL: Race condition détectée ! Plusieurs instances créées en parallèle." + ); + } + + // Le DashMap final ne doit toujours contenir qu'une seule entrée logique. + assert_eq!(cache.entries.len(), 1); + + let _ = fs::remove_file(path.as_ref()); + } + + /// TEST 5 : Sécurité du Flush global à l'extinction du serveur + #[test] + fn test_cache_flush_all() { + let cache = FileCache::new(); + let path1 = PathBuf::from("test_flush_1.enc"); + let path2 = PathBuf::from("test_flush_2.enc"); + let _ = fs::remove_file(&path1); + let _ = fs::remove_file(&path2); + + let _ = cache + .get_or_open(&path1, dummy_cipher(), true, true) + .unwrap(); + let _ = cache + .get_or_open(&path2, dummy_cipher(), true, true) + .unwrap(); + + // Ne doit ni paniquer, ni créer de deadlock (verrouillage croisé) + cache.flush_all(); + + let _ = fs::remove_file(&path1); + let _ = fs::remove_file(&path2); + } +} diff --git a/src/storage/chunk_io.rs b/src/storage/chunk_io.rs index d7e08be..023334b 100644 --- a/src/storage/chunk_io.rs +++ b/src/storage/chunk_io.rs @@ -3,12 +3,11 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::Path; use crate::crypto::cipher::ChunkCipher; +use crate::storage::header::{FileHeader, HEADER_SIZE, LOGICAL_SIZE_OFFSET}; use crate::utils::memory::SecureBuffer; -// IMPORT de notre module header -use crate::storage::header::{FileHeader, HEADER_SIZE}; -/// Gère les opérations d'entrée/sortie sur un fichier chiffré physique. -/// Conçu spécifiquement pour le streaming WebDAV (lectures/écritures par petits blocs). +const XTS_BLOCK_SIZE: u64 = 16; + pub struct EncryptedFile { file: File, cipher: C, @@ -16,8 +15,6 @@ pub struct EncryptedFile { } impl EncryptedFile { - /// Ouvre un fichier chiffré existant ou initialise un nouveau conteneur vide. - /// `write_access` détermine si l'on demande à l'OS un accès exclusif ou partagé. pub fn open>( path: P, cipher: C, @@ -25,12 +22,9 @@ impl EncryptedFile { write_access: bool, ) -> io::Result { let path_ref = path.as_ref(); - let mut opts = OpenOptions::new(); opts.read(true); - // CRITIQUE : On ne demande les droits d'écriture que si le client WebDAV le veut. - // Cela permet à de multiples processus OS de lire le fichier en même temps. if write_access { opts.write(true).create(true).truncate(false); } @@ -38,9 +32,6 @@ impl EncryptedFile { let mut file = opts.open(path_ref)?; let metadata = file.metadata()?; - // On initialise l'en-tête seulement si : - // 1. Le fichier est nouveau (taille 0) - // 2. Ou si l'utilisateur a explicitement demandé de tronquer le fichier let header = if metadata.len() == 0 || truncate { if !write_access { return Err(io::Error::other( @@ -52,7 +43,6 @@ impl EncryptedFile { new_header.write_to(&mut file)?; new_header } else { - // Sinon, on lit l'en-tête existant FileHeader::read_from(&mut file)? }; @@ -63,89 +53,120 @@ impl EncryptedFile { }) } - /// Lit et déchiffre un bloc de données à un offset LOGIQUE donné. pub fn read_chunk(&mut self, logical_offset: u64, size: usize) -> io::Result { - let physical_offset = HEADER_SIZE + logical_offset; + if size == 0 { + return Ok(SecureBuffer(vec![])); + } + + let align_start = (logical_offset / XTS_BLOCK_SIZE) * XTS_BLOCK_SIZE; + let end_offset = logical_offset + size as u64; + let align_end = end_offset.div_ceil(XTS_BLOCK_SIZE) * XTS_BLOCK_SIZE; + let aligned_size = (align_end - align_start) as usize; + + let physical_offset = HEADER_SIZE + align_start; self.file.seek(SeekFrom::Start(physical_offset))?; - let mut buffer = SecureBuffer(vec![0u8; size]); - let bytes_read = self.file.read(&mut buffer.0)?; + let mut block_buffer = SecureBuffer(vec![0u8; aligned_size]); + let bytes_read = self.file.read(&mut block_buffer.0)?; - buffer.0.truncate(bytes_read); + if bytes_read == 0 { + return Ok(SecureBuffer(vec![])); + } - if bytes_read > 0 { + let valid_crypt_size = (bytes_read / XTS_BLOCK_SIZE as usize) * XTS_BLOCK_SIZE as usize; + if valid_crypt_size > 0 { self.cipher - .process_chunk(&self.header.iv, logical_offset, &mut buffer.0) - .map_err(|_| io::Error::other("Échec du déchiffrement à la volée"))?; + .decrypt_chunk( + &self.header.iv, + align_start, + &mut block_buffer.0[..valid_crypt_size], + ) + .map_err(|_| io::Error::other("Échec du déchiffrement XTS"))?; + } + + let relative_start = (logical_offset - align_start) as usize; + let max_logical_available = + self.header.logical_size.saturating_sub(logical_offset) as usize; + let available_data = valid_crypt_size.saturating_sub(relative_start); + + let copy_len = std::cmp::min(size, std::cmp::min(available_data, max_logical_available)); + + let mut final_buffer = SecureBuffer(vec![0u8; copy_len]); + if copy_len > 0 { + final_buffer + .0 + .copy_from_slice(&block_buffer.0[relative_start..(relative_start + copy_len)]); } - Ok(buffer) + Ok(final_buffer) } - /// Chiffre et écrit un bloc de données à un offset LOGIQUE donné. pub fn write_chunk(&mut self, logical_offset: u64, data: &[u8]) -> io::Result<()> { - let current_logical_size = self.logical_size()?; + if data.is_empty() { + return Ok(()); + } - // --- GESTION DES SPARSE FILES (Remplissage des trous) --- - // Si l'OS demande à écrire au-delà de la fin actuelle du fichier - if logical_offset > current_logical_size { - let gap_size = logical_offset - current_logical_size; - let mut gap_offset = current_logical_size; + let align_start = (logical_offset / XTS_BLOCK_SIZE) * XTS_BLOCK_SIZE; + let end_offset = logical_offset + data.len() as u64; + let align_end = end_offset.div_ceil(XTS_BLOCK_SIZE) * XTS_BLOCK_SIZE; + let aligned_size = (align_end - align_start) as usize; - // On limite la taille du buffer pour ne pas exploser la RAM - // si l'OS demande un saut de 2 Go. (64 Ko par itération) - let chunk_size: u64 = 64 * 1024; + let mut block_buffer = SecureBuffer(vec![0u8; aligned_size]); + let physical_data_size = self.file.metadata()?.len().saturating_sub(HEADER_SIZE); - // On se place à la fin physique actuelle - self.file - .seek(SeekFrom::Start(HEADER_SIZE + current_logical_size))?; + if align_start < physical_data_size { + let physical_offset = HEADER_SIZE + align_start; + self.file.seek(SeekFrom::Start(physical_offset))?; - let mut remaining = gap_size; - while remaining > 0 { - let current_chunk_size = std::cmp::min(remaining, chunk_size) as usize; + let max_read = + std::cmp::min(aligned_size as u64, physical_data_size - align_start) as usize; + let mut temp_buf = vec![0u8; max_read]; + let bytes_read = self.file.read(&mut temp_buf)?; - // 1. Créer un buffer de zéros de la taille du chunk - let mut zero_buffer = SecureBuffer(vec![0u8; current_chunk_size]); + let valid_blocks_size = + (bytes_read / XTS_BLOCK_SIZE as usize) * XTS_BLOCK_SIZE as usize; + block_buffer.0[..valid_blocks_size].copy_from_slice(&temp_buf[..valid_blocks_size]); - // 2. Chiffrer ces zéros avec l'offset logique correct pour maintenir la continuité AES-CTR + if valid_blocks_size > 0 { self.cipher - .process_chunk(&self.header.iv, gap_offset, &mut zero_buffer.0) - .map_err(|_| { - io::Error::other("Échec du chiffrement du remplissage (sparse file)") - })?; - - // 3. Écrire physiquement sur le disque - self.file.write_all(&zero_buffer.0)?; - - gap_offset += current_chunk_size as u64; - remaining -= current_chunk_size as u64; + .decrypt_chunk( + &self.header.iv, + align_start, + &mut block_buffer.0[..valid_blocks_size], + ) + .map_err(|_| io::Error::other("Échec déchiffrement RMW"))?; } } - // --- ÉCRITURE DES DONNÉES DEMANDÉES --- - let mut buffer = SecureBuffer(data.to_vec()); + let relative_start = (logical_offset - align_start) as usize; + block_buffer.0[relative_start..(relative_start + data.len())].copy_from_slice(data); self.cipher - .process_chunk(&self.header.iv, logical_offset, &mut buffer.0) - .map_err(|_| io::Error::other("Échec du chiffrement à la volée"))?; + .encrypt_chunk(&self.header.iv, align_start, &mut block_buffer.0) + .map_err(|_| io::Error::other("Échec chiffrement RMW"))?; - let physical_offset = HEADER_SIZE + logical_offset; + let physical_offset = HEADER_SIZE + align_start; self.file.seek(SeekFrom::Start(physical_offset))?; + self.file.write_all(&block_buffer.0)?; - self.file.write_all(&buffer.0)?; - self.file.flush()?; + let new_logical_end = logical_offset + data.len() as u64; + + if new_logical_end > self.header.logical_size { + self.header.logical_size = new_logical_end; + self.file.seek(SeekFrom::Start(LOGICAL_SIZE_OFFSET))?; + self.file + .write_all(&self.header.logical_size.to_le_bytes())?; + } Ok(()) } - /// Retourne la taille logique du fichier (taille du fichier clair simulé). + pub fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } + pub fn logical_size(&self) -> io::Result { - let physical_size = self.file.metadata()?.len(); - if physical_size >= HEADER_SIZE { - Ok(physical_size - HEADER_SIZE) - } else { - Ok(0) - } + Ok(self.header.logical_size) } pub fn metadata(&self) -> io::Result { @@ -156,60 +177,197 @@ impl EncryptedFile { #[cfg(test)] mod tests { use super::*; - use crate::crypto::cipher::Aes256CtrCipher; + use crate::crypto::cipher::Aes256XtsCipher; use crate::utils::memory::SecureKey; use std::fs; + // --- Helper --- + fn setup_test_file(path: &str) -> EncryptedFile { + let _ = fs::remove_file(path); + let key = SecureKey(vec![0x42; 64]); + EncryptedFile::open(path, Aes256XtsCipher::new(key), true, true).unwrap() + } + + // ---------------------------------------------------------------- + // TESTS EXISTANTS (Basiques) + // ---------------------------------------------------------------- + #[test] + fn test_encrypted_file_readonly_not_found() { + let key = SecureKey(vec![0x42; 64]); + let result = EncryptedFile::open( + "does_not_exist.enc", + Aes256XtsCipher::new(key), + false, + false, + ); + assert!( + result.is_err(), + "L'ouverture d'un fichier inexistant en lecture seule doit échouer" + ); + } + #[test] - fn test_webdav_streaming_scenario_with_header() { - let key = SecureKey(vec![0x42; 32]); - let test_file_path = "test_streaming_scenario_final.enc"; - - let _ = fs::remove_file(test_file_path); - - // --- ÉTAPE 1 : Création --- - // L'IV n'est plus passé manuellement, FileHeader::generate_new() s'en charge. - let mut enc_file = EncryptedFile::open( - test_file_path, - Aes256CtrCipher::new(key.clone()), - true, - true, - ) - .expect("Échec de la création du fichier"); + fn test_encrypted_file_metadata() { + let path = "test_metadata.enc"; + let mut enc_file = setup_test_file(path); + enc_file.write_chunk(0, b"metadata test").unwrap(); + let meta = enc_file.metadata().unwrap(); + assert!(meta.is_file()); + assert!( + meta.len() > HEADER_SIZE, + "La taille physique doit inclure l'en-tête et les données" + ); + let _ = fs::remove_file(path); + } + + // ---------------------------------------------------------------- + // TESTS CRITIQUES (Nouveaux - Extrême Robustesse) + // ---------------------------------------------------------------- + + /// TEST 1 : RMW à cheval sur plusieurs blocs cryptographiques + /// Vérifie que si on écrit de l'octet 10 à 25 (chevauchement sur les blocs 0 et 1), + /// le système déchiffre les deux blocs, modifie le milieu, et rechiffre sans corrompre les extrémités. + #[test] + fn test_chunk_io_cross_block_rmw() { + let path = "test_cross_block.enc"; + let mut enc_file = setup_test_file(path); + + // 1. On initialise 2 blocs complets (32 octets) avec des 'A' + let initial_data = [b'A'; 32]; + enc_file.write_chunk(0, &initial_data).unwrap(); + + // 2. Écriture DÉSALIGNÉE de 10 octets commençant à l'offset 14 (finit à 24) + // Bloc 0 : de 0 à 15 (touché à la fin) + // Bloc 1 : de 16 à 31 (touché au début) + let inject_data = b"0123456789"; + enc_file.write_chunk(14, inject_data).unwrap(); + + // 3. Lecture et vérification globale + let result = enc_file.read_chunk(0, 32).unwrap(); + + let mut expected = [b'A'; 32]; + expected[14..24].copy_from_slice(b"0123456789"); assert_eq!( - enc_file.logical_size().unwrap(), + result.0.as_slice(), + expected, + "CRITICAL: L'écriture Read-Modify-Write a corrompu les données aux limites de blocs XTS !" + ); + + let _ = fs::remove_file(path); + } + + /// TEST 2 : Sécurité de la frontière EOF (End Of File) + /// Empêche le client de lire la "poubelle" (padding) générée par le chiffrement de bloc. + #[test] + fn test_chunk_io_eof_read_behavior() { + let path = "test_eof.enc"; + let mut enc_file = setup_test_file(path); + + // On écrit 5 octets. Physique = 16 octets (1 bloc paddé avec des zéros chiffrés) + enc_file.write_chunk(0, b"Hello").unwrap(); + assert_eq!(enc_file.logical_size().unwrap(), 5); + + // Tentative de lecture de 20 octets (débordement) + let result = enc_file.read_chunk(0, 20).unwrap(); + + assert_eq!( + result.0.len(), + 5, + "CRITICAL: Le lecteur a retourné le padding cryptographique caché de XTS ou des déchets mémoire !" + ); + assert_eq!(result.0.as_slice(), b"Hello"); + + // Tentative de lecture purement hors limites + let oob_result = enc_file.read_chunk(10, 5).unwrap(); + assert_eq!( + oob_result.0.len(), 0, - "Le fichier logique doit être vide au départ." + "Une lecture hors du fichier logique doit renvoyer 0 octet." ); - // --- ÉTAPE 2 : Streaming --- - let chunk1 = b"Bonjour, ceci "; // 14 octets - let chunk2 = b"est un test de "; // 15 octets - let chunk3 = b"streaming OS."; // 13 octets + let _ = fs::remove_file(path); + } - enc_file.write_chunk(0, chunk1).unwrap(); - enc_file.write_chunk(14, chunk2).unwrap(); - enc_file.write_chunk(29, chunk3).unwrap(); + /// TEST 3 : Comportement face aux I/O de taille Zéro + /// L'OS (surtout Linux/macOS) fait parfois des appels "vides" pour tester les accès. + #[test] + fn test_chunk_io_zero_length_operations() { + let path = "test_zero_len.enc"; + let mut enc_file = setup_test_file(path); - assert_eq!(enc_file.logical_size().unwrap(), 42); + enc_file.write_chunk(0, b"Data").unwrap(); - // --- ÉTAPE 3 : Fermeture et Réouverture --- - // On détruit l'instance en mémoire pour forcer une réouverture depuis le disque - drop(enc_file); + // Écriture vide + enc_file.write_chunk(2, &[]).unwrap(); + assert_eq!( + enc_file.logical_size().unwrap(), + 4, + "L'écriture vide a modifié la taille logique !" + ); + + // Lecture vide + let result = enc_file.read_chunk(1, 0).unwrap(); + assert_eq!(result.0.len(), 0, "La lecture vide a renvoyé des données !"); - // On ouvre le fichier existant en lecture seule (create/truncate: false, write_access: false). - let mut read_file = - EncryptedFile::open(test_file_path, Aes256CtrCipher::new(key), false, false) - .expect("Échec de la réouverture du fichier"); + let _ = fs::remove_file(path); + } + + /// TEST 4 : Truncate et Sécurité Cryptographique (Rotation de l'IV) + /// Écraser un fichier DOIT générer un nouveau IV, sinon l'AES-XTS perd toute sa force. + #[test] + fn test_chunk_io_truncate_and_iv_rotation() { + let path = "test_truncate_iv.enc"; + let key = SecureKey(vec![0x42; 64]); + + // 1. Première vie du fichier + let mut file1 = + EncryptedFile::open(path, Aes256XtsCipher::new(key.clone()), true, true).unwrap(); + file1 + .write_chunk(0, b"Anciennes donnees confidentielles") + .unwrap(); + let iv1 = file1.header.iv; + drop(file1); + + // 2. Seconde vie du fichier (Truncate par l'OS) + let file2 = EncryptedFile::open(path, Aes256XtsCipher::new(key), true, true).unwrap(); + let iv2 = file2.header.iv; - let read_offset = 21; - let read_size = 4; - let read_buffer = read_file.read_chunk(read_offset, read_size).unwrap(); + assert_eq!( + file2.logical_size().unwrap(), + 0, + "Truncate n'a pas mis la taille logique à zéro" + ); + assert_ne!( + iv1, iv2, + "CRITICAL: Faille cryptographique ! L'IV n'a pas été renouvelé lors du truncate." + ); - // Si le mot "test" est bien déchiffré, cela prouve que le programme a correctement lu l'IV dans l'en-tête ! - assert_eq!(read_buffer.0.as_slice(), b"test"); + let _ = fs::remove_file(path); + } + + /// TEST 5 : Arithmétique des très grands blocs (Stress Test Mémoire) + #[test] + fn test_chunk_io_large_file_multiple_chunks() { + let path = "test_large_chunks.enc"; + let mut enc_file = setup_test_file(path); + + // Payload de 10 000 octets (ni aligné sur 16, ni un multiple parfait) + let payload: Vec = (0..10000).map(|i| (i % 256) as u8).collect(); + + // On écrit tout d'un coup + enc_file.write_chunk(0, &payload).unwrap(); + assert_eq!(enc_file.logical_size().unwrap(), 10000); + + // On relit le tout + let result = enc_file.read_chunk(0, 10000).unwrap(); + assert_eq!(result.0.len(), 10000); + assert_eq!( + result.0.as_slice(), + payload.as_slice(), + "L'écriture d'un gros buffer a été corrompue" + ); - let _ = fs::remove_file(test_file_path); + let _ = fs::remove_file(path); } } diff --git a/src/storage/header.rs b/src/storage/header.rs index 4b96e04..4bc624f 100644 --- a/src/storage/header.rs +++ b/src/storage/header.rs @@ -1,93 +1,176 @@ -use std::fs::File; -use std::io::{self, Read, Seek, SeekFrom, Write}; -// CORRECTION ICI : On importe le trait Rng use rand::Rng; +use std::io::{self, Read, Write}; -/// Signature unique pour identifier les fichiers de notre application (Dynamic Secure Portable Volume). -/// "DSPV" en ASCII. -pub const MAGIC_NUMBER: [u8; 4] = [0x44, 0x53, 0x50, 0x56]; +pub const HEADER_SIZE: u64 = 32; +// L'IV fait 16 octets, donc la taille logique commence exactement à l'octet 16. +pub const LOGICAL_SIZE_OFFSET: u64 = 16; -/// La taille totale de l'en-tête (Magic Number + IV). -/// 4 octets (Magic) + 16 octets (IV) = 20 octets. -pub const HEADER_SIZE: u64 = 20; - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct FileHeader { pub iv: [u8; 16], + pub logical_size: u64, + pub reserved: [u8; 8], } impl FileHeader { - /// Génère un nouvel en-tête avec un IV aléatoire sécurisé. pub fn generate_new() -> Self { let mut iv = [0u8; 16]; rand::rng().fill_bytes(&mut iv); - Self { iv } - } - - /// Écrit l'en-tête (Magic Number + IV) au tout début d'un fichier. - pub fn write_to(&self, file: &mut File) -> io::Result<()> { - file.seek(SeekFrom::Start(0))?; - file.write_all(&MAGIC_NUMBER)?; - file.write_all(&self.iv)?; - file.flush()?; - Ok(()) + Self { + iv, + logical_size: 0, + reserved: [0u8; 8], + } } - /// Lit et valide l'en-tête d'un fichier existant. - pub fn read_from(file: &mut File) -> io::Result { - file.seek(SeekFrom::Start(0))?; + pub fn read_from(mut reader: R) -> io::Result { + let mut iv = [0u8; 16]; + reader.read_exact(&mut iv)?; - // 1. Vérification de la signature (Magic Number) - let mut magic = [0u8; 4]; - file.read_exact(&mut magic)?; + let mut size_bytes = [0u8; 8]; + reader.read_exact(&mut size_bytes)?; - if magic != MAGIC_NUMBER { - return Err(io::Error::other( - "Fichier invalide : signature DSPV manquante ou corrompue.", - )); - } + let mut reserved = [0u8; 8]; + reader.read_exact(&mut reserved)?; - // 2. Lecture du Vecteur d'Initialisation (IV) - let mut iv = [0u8; 16]; - file.read_exact(&mut iv)?; + Ok(Self { + iv, + logical_size: u64::from_le_bytes(size_bytes), + reserved, + }) + } - Ok(Self { iv }) + pub fn write_to(&self, mut writer: W) -> io::Result<()> { + writer.write_all(&self.iv)?; + writer.write_all(&self.logical_size.to_le_bytes())?; + writer.write_all(&self.reserved)?; + Ok(()) } } #[cfg(test)] mod tests { use super::*; - use std::fs::OpenOptions; + use std::io::{self, Cursor}; + + // ---------------------------------------------------------------- + // TESTS EXISTANTS (Fonctionnalité de base) + // ---------------------------------------------------------------- #[test] - fn test_header_generation_and_io() { - let test_file = "test_header.enc"; - let _ = std::fs::remove_file(test_file); + fn test_header_generate_new() { + let header1 = FileHeader::generate_new(); + let header2 = FileHeader::generate_new(); - let original_header = FileHeader::generate_new(); + assert_eq!(header1.logical_size, 0); + assert_eq!(header1.reserved, [0u8; 8]); + assert_ne!(header1.iv, header2.iv, "L'IV doit être aléatoire"); + } - { - let mut file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(test_file) - .unwrap(); + #[test] + fn test_header_generation_and_io() { + let header = FileHeader { + iv: [0x42; 16], + logical_size: 1337, + reserved: [0u8; 8], + }; + let mut buffer = Vec::new(); + header.write_to(&mut buffer).unwrap(); + assert_eq!(buffer.len(), HEADER_SIZE as usize); + + let mut cursor = Cursor::new(buffer); + let read_header = FileHeader::read_from(&mut cursor).unwrap(); + assert_eq!(header.iv, read_header.iv); + assert_eq!(header.logical_size, read_header.logical_size); + } - original_header.write_to(&mut file).unwrap(); - } + // ---------------------------------------------------------------- + // TESTS CRITIQUES (Résilience Extrême) + // ---------------------------------------------------------------- - { - let mut file = OpenOptions::new().read(true).open(test_file).unwrap(); - let read_header = FileHeader::read_from(&mut file).unwrap(); + /// TEST 1 : Fichier corrompu ou incomplet (Short Read) + /// Un explorateur natif peut parfois créer un fichier de 0 octet ou l'interrompre. + #[test] + fn test_header_short_read_prevents_panic() { + // Un buffer de 20 octets (IV lu, mais la taille logique est coupée en plein milieu) + let buffer = vec![0u8; 20]; + let mut cursor = Cursor::new(buffer); + + let result = FileHeader::read_from(&mut cursor); + + assert!( + result.is_err(), + "CRITICAL: Le lecteur a accepté un header incomplet sans déclencher d'erreur !" + ); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::UnexpectedEof, + "L'erreur renvoyée doit être exactement UnexpectedEof pour être gérée proprement par chunk_io" + ); + } - assert_eq!( - original_header, read_header, - "L'IV lu doit correspondre à l'IV écrit" - ); - } + /// TEST 2 : Stabilité Cross-Platform (Endianness) + /// Si le volume est monté sur une architecture Big Endian vs Little Endian, + /// la taille logique ne doit pas être altérée. + #[test] + fn test_header_endianness_crossplatform_guarantee() { + let mut header = FileHeader::generate_new(); + // Valeur hexadécimale asymétrique pour repérer facilement l'ordre des octets + header.logical_size = 0x1122334455667788; + + let mut buffer = Vec::new(); + header.write_to(&mut buffer).unwrap(); + + // L'offset 16 correspond à `logical_size`. + // L'appel `to_le_bytes` exige que le byte de poids faible (0x88) soit le premier écrit. + let size_bytes = &buffer[16..24]; + let expected_bytes: [u8; 8] = [0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]; + + assert_eq!( + size_bytes, &expected_bytes, + "CRITICAL: La sérialisation n'est pas en Little Endian strict. Le volume sera corrompu d'une architecture à l'autre !" + ); + } - let _ = std::fs::remove_file(test_file); + /// TEST 3 : Stabilité des offsets physiques (Layout Binaire) + /// Empêche une future modification du code d'inverser par erreur + /// la position de la taille et de l'IV dans le fichier physique. + #[test] + fn test_header_binary_layout_strictness() { + let mut buffer = Vec::new(); + let header = FileHeader { + iv: [0xFF; 16], + logical_size: 255, // 0x00000000000000FF (255) + reserved: [0xAA; 8], + }; + header.write_to(&mut buffer).unwrap(); + + // 1. Vérification de la taille totale dictée par AES-XTS + assert_eq!( + buffer.len(), + 32, + "Le header doit faire EXACTEMENT 32 octets" + ); + + // 2. L'IV doit occuper le premier bloc de 16 octets (offset 0 à 15) + assert_eq!( + &buffer[0..16], + &[0xFF; 16], + "L'IV a été décalé de son offset physique d'origine" + ); + + // 3. La taille doit occuper l'offset défini par LOGICAL_SIZE_OFFSET (16 à 23) + assert_eq!( + &buffer[16..24], + &[0xFF, 0, 0, 0, 0, 0, 0, 0], + "La taille logique n'est plus à l'offset 16" + ); + + // 4. Les octets réservés pour s'aligner sur 32 (offset 24 à 31) + assert_eq!( + &buffer[24..32], + &[0xAA; 8], + "La zone réservée n'est pas alignée à la fin du header" + ); } } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index cfd7942..b6e00ab 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,3 +1,4 @@ +pub mod cache; pub mod chunk_io; pub mod header; pub mod vault; diff --git a/src/storage/vault.rs b/src/storage/vault.rs index 563cfcb..339154d 100644 --- a/src/storage/vault.rs +++ b/src/storage/vault.rs @@ -1,10 +1,9 @@ -// storage/vault.rs use rand::Rng; use std::fs::File; use std::io::{self, Read, Write}; use std::path::Path; -use crate::crypto::cipher::{Aes256CtrCipher, ChunkCipher}; +use crate::crypto::cipher::{Aes256XtsCipher, ChunkCipher}; use crate::crypto::kdf::{Argon2Kdf, KeyDerivation}; use crate::utils::memory::SecureKey; @@ -15,7 +14,6 @@ const VERIFY_BLOCK_SIZE: usize = 32; pub struct VaultManager; impl VaultManager { - /// Initialise un nouveau volume ou déverrouille un volume existant pub fn unlock_or_create>( physical_root: P, password: &str, @@ -33,20 +31,17 @@ impl VaultManager { let mut salt = [0u8; SALT_SIZE]; rand::rng().fill_bytes(&mut salt); - // Dérivation de la clé let master_key = Argon2Kdf::derive_key(password, &salt).map_err(|_| io::Error::other("Échec KDF"))?; - // Création du bloc de vérification (32 octets de zéros chiffrés avec un IV fixe pour la meta) let mut verify_block = [0u8; VERIFY_BLOCK_SIZE]; - let meta_iv = [0u8; 16]; // IV statique uniquement pour le bloc de vérification - let cipher = Aes256CtrCipher::new(master_key.clone()); + let meta_iv = [0u8; 16]; + let cipher = Aes256XtsCipher::new(master_key.clone()); cipher - .process_chunk(&meta_iv, 0, &mut verify_block) + .encrypt_chunk(&meta_iv, 0, &mut verify_block) .map_err(|_| io::Error::other("Échec chiffrement bloc vérification"))?; - // Écriture du fichier meta let mut file = File::create(meta_path)?; file.write_all(VAULT_MAGIC)?; file.write_all(&salt)?; @@ -71,19 +66,16 @@ impl VaultManager { let mut verify_block = [0u8; VERIFY_BLOCK_SIZE]; file.read_exact(&mut verify_block)?; - // Dérivation avec le sel lu let master_key = Argon2Kdf::derive_key(password, &salt).map_err(|_| io::Error::other("Échec KDF"))?; - // Tentative de déchiffrement du bloc de vérification - let cipher = Aes256CtrCipher::new(master_key.clone()); + let cipher = Aes256XtsCipher::new(master_key.clone()); let meta_iv = [0u8; 16]; cipher - .process_chunk(&meta_iv, 0, &mut verify_block) + .decrypt_chunk(&meta_iv, 0, &mut verify_block) .map_err(|_| io::Error::other("Échec déchiffrement bloc vérification"))?; - // Vérification : le bloc déchiffré doit contenir uniquement des zéros if verify_block != [0u8; VERIFY_BLOCK_SIZE] { return Err(io::Error::other("Mot de passe incorrect")); } @@ -91,3 +83,162 @@ impl VaultManager { Ok(master_key) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, OpenOptions}; + use std::io::{Seek, SeekFrom}; + + // --- Helper --- + fn setup_test_env(name: &str) -> String { + let _ = fs::remove_dir_all(name); + fs::create_dir_all(name).unwrap(); + name.to_string() + } + + fn teardown_test_env(name: &str) { + let _ = fs::remove_dir_all(name); + } + + /// TEST 1 : Le cycle de vie normal (Création et Déverrouillage valide) + #[test] + fn test_vault_lifecycle_success() { + let root = setup_test_env("test_vault_lifecycle"); + let password = "super_secure_password_123!"; + + // 1. Création + let key_1 = VaultManager::unlock_or_create(&root, password).expect("Création échouée"); + assert!(Path::new(&root).join("dspv.meta").exists()); + + // 2. Déverrouillage + let key_2 = VaultManager::unlock_or_create(&root, password).expect("Déverrouillage échoué"); + + // La clé en RAM doit être strictement identique + assert_eq!(key_1.0, key_2.0, "Les clés dérivées ne correspondent pas !"); + + teardown_test_env(&root); + } + + /// TEST 2 : Le rejet stricte d'un mauvais mot de passe + #[test] + fn test_vault_wrong_password() { + let root = setup_test_env("test_vault_wrong_pwd"); + + VaultManager::unlock_or_create(&root, "good_password").unwrap(); + let result = VaultManager::unlock_or_create(&root, "bad_password"); + + assert!( + result.is_err(), + "CRITICAL: Le système a accepté un mauvais mot de passe !" + ); + assert_eq!(result.unwrap_err().to_string(), "Mot de passe incorrect"); + + teardown_test_env(&root); + } + + /// TEST 3 : Résilience face à un fichier tronqué (Short Read) + /// Empêche le programme de paniquer si le fichier meta fait moins de 68 octets. + #[test] + fn test_vault_truncated_file_no_panic() { + let root = setup_test_env("test_vault_truncated"); + let meta_path = Path::new(&root).join("dspv.meta"); + + // On forge un fichier avec seulement le Magic Number et un bout de sel (10 octets au total) + let mut file = File::create(&meta_path).unwrap(); + file.write_all(b"DSPM").unwrap(); + file.write_all(&[0x42; 6]).unwrap(); + drop(file); + + let result = VaultManager::unlock_or_create(&root, "password"); + + assert!( + result.is_err(), + "Le système doit rejeter un fichier tronqué" + ); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::UnexpectedEof, + "L'erreur doit être UnexpectedEof (fin de fichier prématurée), pas un crash système" + ); + + teardown_test_env(&root); + } + + /// TEST 4 : Tentative de falsification du Sel (Salt Tampering) + /// Modifier 1 seul octet du sel doit altérer le résultat du KDF et rejeter l'accès. + #[test] + fn test_vault_salt_tampering() { + let root = setup_test_env("test_vault_salt_tamper"); + let meta_path = Path::new(&root).join("dspv.meta"); + let password = "password"; + + VaultManager::unlock_or_create(&root, password).unwrap(); + + // Ouverture en mode modification binaire + let mut file = OpenOptions::new().write(true).open(&meta_path).unwrap(); + + // Le sel commence à l'offset 4 (après "DSPM"). On modifie l'octet à l'offset 10. + file.seek(SeekFrom::Start(10)).unwrap(); + file.write_all(&[0xFF]).unwrap(); + drop(file); + + let result = VaultManager::unlock_or_create(&root, password); + + assert!( + result.is_err(), + "CRITICAL: La modification du sel n'a pas invalidé le coffre !" + ); + + teardown_test_env(&root); + } + + /// TEST 5 : Tentative de falsification du bloc de vérification chiffré + /// Si un attaquant modifie la signature chiffrée, le déchiffrement donnera + /// un résultat différent de [0; 32] et l'accès doit être bloqué. + #[test] + fn test_vault_verify_block_tampering() { + let root = setup_test_env("test_vault_block_tamper"); + let meta_path = Path::new(&root).join("dspv.meta"); + let password = "password"; + + VaultManager::unlock_or_create(&root, password).unwrap(); + + let mut file = OpenOptions::new().write(true).open(&meta_path).unwrap(); + + // Le bloc de vérification commence à l'offset 36 (4 Magic + 32 Sel). + file.seek(SeekFrom::Start(40)).unwrap(); + file.write_all(&[0xFF]).unwrap(); + drop(file); + + let result = VaultManager::unlock_or_create(&root, password); + + assert!( + result.is_err(), + "CRITICAL: Le coffre s'est ouvert malgré un bloc de vérification corrompu !" + ); + + teardown_test_env(&root); + } + + /// TEST 6 : Gestion d'un mot de passe vide + /// Vérifie que l'algorithme KDF (Argon2) est capable d'ingérer une chaîne vide proprement. + #[test] + fn test_vault_empty_password_handling() { + let root = setup_test_env("test_vault_empty_pwd"); + + let result_create = VaultManager::unlock_or_create(&root, ""); + assert!( + result_create.is_ok(), + "Le système doit pouvoir gérer un mot de passe vide sans planter" + ); + + let result_unlock = VaultManager::unlock_or_create(&root, ""); + assert!( + result_unlock.is_ok(), + "Le système doit pouvoir déverrouiller avec un mot de passe vide" + ); + + teardown_test_env(&root); + } +} diff --git a/src/utils/memory.rs b/src/utils/memory.rs index acada4d..858bf69 100644 --- a/src/utils/memory.rs +++ b/src/utils/memory.rs @@ -1,3 +1,4 @@ +use std::fmt; use zeroize::{Zeroize, ZeroizeOnDrop}; /// Représente une clé cryptographique qui s'auto-détruit (zeroize) @@ -8,3 +9,10 @@ pub struct SecureKey(pub Vec); /// Buffer sécurisé pour les données en clair lues ou écrites. #[derive(Zeroize, ZeroizeOnDrop)] pub struct SecureBuffer(pub Vec); + +impl fmt::Debug for SecureKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // On ne révèle JAMAIS le contenu de la clé dans les logs ou les panics + write!(f, "SecureKey([CENSURÉ])") + } +}