use std::collections::{HashMap, HashSet}; use std::path::Path; use anyhow::{bail, Context, Result}; use zeroize::Zeroizing; use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2}; use crate::storage::Database; /// Bericht über das Ergebnis einer Container-Integritätsprüfung. #[derive(Debug, Clone)] pub struct VerificationReport { pub container_path: String, pub format_version: u32, pub sqlite_ok: bool, pub sqlite_errors: Vec, pub header_ok: bool, pub header_error: Option, pub total_nodes: usize, pub total_dirs: usize, pub total_files: usize, pub total_chunks: usize, pub total_bytes_decrypted: u64, pub corrupted_chunks: usize, pub orphan_nodes: usize, pub errors: Vec, } impl VerificationReport { pub fn is_healthy(&self) -> bool { self.sqlite_ok && self.header_ok && self.corrupted_chunks == 0 && self.orphan_nodes == 0 && self.errors.is_empty() } } /// Führt eine detaillierte Integritäts- und Bitrot-Prüfung auf einem Sanctum-Container durch. pub fn verify_container( container_path: &Path, dek: Option<&Zeroizing<[u8; 32]>>, full_chunks: bool, ) -> Result { if !container_path.exists() { bail!("Containerdatei '{}' existiert nicht.", container_path.display()); } let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; let mut report = VerificationReport { container_path: container_path.display().to_string(), format_version: 0, sqlite_ok: true, sqlite_errors: Vec::new(), header_ok: true, header_error: None, total_nodes: 0, total_dirs: 0, total_files: 0, total_chunks: 0, total_bytes_decrypted: 0, corrupted_chunks: 0, orphan_nodes: 0, errors: Vec::new(), }; // 1. SQLite B-Tree & Foreign Key Prüfung let sqlite_issues = db.run_sqlite_integrity_check() .context("Fehler bei der Ausführung des SQLite integrity_check")?; if !sqlite_issues.is_empty() { report.sqlite_ok = false; report.sqlite_errors = sqlite_issues; } // 2. Header & Magic Bytes Prüfung let meta = match db.read_meta() { Ok(m) => { report.format_version = m.version; if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 { report.header_ok = false; report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version)); } if m.kdf_salt.len() != 16 { report.header_ok = false; report.header_error = Some("Ungültige KDF-Salt-Länge".to_string()); } Some(m) } Err(e) => { report.header_ok = false; report.header_error = Some(format!("{e}")); None } }; // 3. Node-Hierarchie & Strukturprüfung let (dirs, files, chunks_count) = db.count_nodes_and_chunks() .context("Fehler beim Zählen der Knoten und Chunks")?; report.total_dirs = dirs; report.total_files = files; report.total_chunks = chunks_count; let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?; report.total_nodes = all_nodes.len(); let mut node_map = HashMap::new(); for node in &all_nodes { node_map.insert(node.id, node.clone()); } // Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault) match node_map.get(&1) { Some(root) => { if !root.is_dir { report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string()); } if root.parent_id.is_some() { report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string()); } } None => { report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string()); } } if let Some(root2) = node_map.get(&2) { if !root2.is_dir { report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string()); } if root2.parent_id.is_some() { report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string()); } } // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen for node in &all_nodes { if node.id == 1 || node.id == 2 { continue; } match node.parent_id { Some(pid) => match node_map.get(&pid) { Some(parent) => { if !parent.is_dir { report.errors.push(format!( "Knoten '{}' (id={}) hat einen Parent (id={}), der kein Verzeichnis ist!", node.name, node.id, pid )); } } None => { report.orphan_nodes += 1; report.errors.push(format!( "Verwaister Knoten: '{}' (id={}) verweist auf nicht-existenten Parent id={}", node.name, node.id, pid )); } }, None => { report.orphan_nodes += 1; report.errors.push(format!( "Verwaister Knoten ohne Parent: '{}' (id={})", node.name, node.id )); } } // Zyklenprüfung let mut visited = HashSet::new(); visited.insert(node.id); let mut curr_parent = node.parent_id; while let Some(pid) = curr_parent { if !visited.insert(pid) { report.errors.push(format!( "Zyklische Verzeichnisreferenz bei Knoten '{}' (id={}) entdeckt!", node.name, node.id )); break; } curr_parent = node_map.get(&pid).and_then(|n| n.parent_id); } } // Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1) let mut vault0_nodes = HashSet::new(); let mut vault1_nodes = HashSet::new(); vault0_nodes.insert(1i64); if node_map.contains_key(&2) { vault1_nodes.insert(2i64); } let mut changed = true; while changed { changed = false; for node in &all_nodes { if let Some(pid) = node.parent_id { if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) { vault0_nodes.insert(node.id); changed = true; } else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) { vault1_nodes.insert(node.id); changed = true; } } } } // Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört let active_vault_nodes = if let Some(active_dek) = dek { let is_vault1 = { let mut found_v1 = false; for node in &all_nodes { if node.parent_id == Some(2) { if crate::crypto::decrypt_node_name(active_dek, 2, &node.name).is_some() { found_v1 = true; break; } } } found_v1 }; if is_vault1 { Some(&vault1_nodes) } else { Some(&vault0_nodes) } } else { None }; // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung let chunk_headers = db.list_all_chunk_headers() .context("Fehler beim Abrufen der Chunk-Liste")?; let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2); for (node_id, chunk_index) in chunk_headers { if !node_map.contains_key(&node_id) { report.errors.push(format!( "Verwaister Daten-Chunk: Node #{node_id} Chunk #{chunk_index} gehört zu keinem bekannten Inode!" )); } if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) { // Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault) if target_nodes.contains(&node_id) && full_chunks { match db.read_chunk(node_id, chunk_index) { Ok(Some(record)) => { match decrypt_chunk( active_dek, node_id, chunk_index, &record.ciphertext, &record.nonce, &record.tag, format_version, ) { Ok(plaintext) => { report.total_bytes_decrypted += plaintext.len() as u64; } Err(e) => { report.corrupted_chunks += 1; report.errors.push(format!( "AEAD/Integritätsfehler bei Node #{node_id} Chunk #{chunk_index}: {e}" )); } } } Ok(None) => { report.errors.push(format!( "Chunk #{chunk_index} für Node #{node_id} in Index gefunden, aber Daten nicht lesbar!" )); } Err(e) => { report.corrupted_chunks += 1; report.errors.push(format!( "DB-Lesefehler bei Node #{node_id} Chunk #{chunk_index}: {e}" )); } } } } } Ok(report) } #[cfg(test)] mod tests { use super::*; use crate::crypto::{ derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION, }; use std::fs; use std::path::PathBuf; #[test] fn test_verify_healthy_container() { let temp_dir = std::env::temp_dir(); let container_path: PathBuf = temp_dir.join(format!("test_verify_ok_{}.sanctum", std::process::id())); if container_path.exists() { let _ = fs::remove_file(&container_path); } let password = "HealthyTestPassword123!"; let salt = generate_salt(); let kdf_params = KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1, }; let kek = derive_kek(password, &salt, &kdf_params).unwrap(); let dek = generate_dek(); let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); // Verzeichnis & Datei anlegen let folder = db.create_node(1, "photos", true).unwrap(); let file = db.create_node(folder.id, "img.jpg", false).unwrap(); // 2 Chunks schreiben let chunk0_data = b"Sample JPEG data header and pixels"; let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap(); db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap(); let chunk1_data = b"Additional payload data bytes"; let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap(); db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap(); db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap(); db.checkpoint().unwrap(); // Verifizieren let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); assert_eq!(report.total_files, 1); assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos assert_eq!(report.total_chunks, 2); assert_eq!(report.corrupted_chunks, 0); assert_eq!(report.orphan_nodes, 0); let _ = fs::remove_file(&container_path); } #[test] fn test_verify_detects_bitrot() { let temp_dir = std::env::temp_dir(); let container_path: PathBuf = temp_dir.join(format!("test_verify_bitrot_{}.sanctum", std::process::id())); if container_path.exists() { let _ = fs::remove_file(&container_path); } let password = "BitrotTestPassword123!"; let salt = generate_salt(); let kdf_params = KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1, }; let kek = derive_kek(password, &salt, &kdf_params).unwrap(); let dek = generate_dek(); let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); let file = db.create_node(1, "document.pdf", false).unwrap(); let chunk_data = b"Vital documents that must not be corrupted"; let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap(); db.write_chunk(file.id, 0, &n, &t, &ct).unwrap(); db.checkpoint().unwrap(); drop(db); // Bitrot simulieren: Wir flippen 1 Byte im Ciphertext in SQLite direkt let conn = rusqlite::Connection::open(&container_path).unwrap(); let mut corrupted_ct = ct.clone(); corrupted_ct[4] ^= 0xFF; // Bit-Flip! conn.execute( "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0", rusqlite::params![corrupted_ct, file.id], ).unwrap(); drop(conn); // Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven! let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot"); assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk"); assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler"))); let _ = fs::remove_file(&container_path); } }