diff --git a/src/crypto.rs b/src/crypto.rs index 7080525..11b7082 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -129,6 +129,70 @@ pub fn unwrap_dek( Ok(dek) } +/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie +/// ein echter KDF/DEK-Slot. Dadurch sind Standard-Container von Containern mit Hidden Vault +/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). +pub fn generate_dummy_slot() -> (Vec, [u8; 12], [u8; 16], [u8; 16]) { + let mut wrapped_dek = vec![0u8; 32]; + let mut nonce = [0u8; 12]; + let mut tag = [0u8; 16]; + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut wrapped_dek); + OsRng.fill_bytes(&mut nonce); + OsRng.fill_bytes(&mut tag); + OsRng.fill_bytes(&mut salt); + (wrapped_dek, nonce, tag, salt) +} + +/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM. +/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind. +pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init"); + let mut buffer = name.as_bytes().to_vec(); + let tag = cipher + .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer) + .expect("Name encryption"); + format!( + "$h${}${}${}", + hex::encode(nonce_bytes), + hex::encode(tag.as_slice()), + hex::encode(&buffer) + ) +} + +/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. +pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option { + if let Some(rest) = stored.strip_prefix("$h$") { + let parts: Vec<&str> = rest.split('$').collect(); + if parts.len() == 3 { + if let (Ok(nonce_bytes), Ok(tag_bytes), Ok(ct_bytes)) = ( + hex::decode(parts[0]), + hex::decode(parts[1]), + hex::decode(parts[2]), + ) { + if nonce_bytes.len() == 12 && tag_bytes.len() == 16 { + let cipher = Aes256Gcm::new_from_slice(dek).ok()?; + let mut buffer = ct_bytes; + if cipher + .decrypt_in_place_detached( + Nonce::from_slice(&nonce_bytes), + b"SANCTUM_NODE_NAME", + &mut buffer, + Tag::from_slice(&tag_bytes), + ) + .is_ok() + { + return String::from_utf8(buffer).ok(); + } + } + } + } + } + None +} + /// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme. pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result { let mnemonic = bip39::Mnemonic::from_entropy(dek) @@ -412,5 +476,28 @@ mod tests { let corrupted_phrase = words.join(" "); assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); } + + #[test] + fn test_hidden_node_name_encryption_and_dummy_slot() { + let dek = generate_dek(); + let filename = "ultra_geheimes_dokument.pdf"; + let encrypted = encrypt_node_name(&dek, filename); + assert!(encrypted.starts_with("$h$")); + assert!(!encrypted.contains(filename)); + + let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name"); + assert_eq!(decrypted, filename); + + // Mit anderem DEK schlägt Entschlüsselung fehl + let other_dek = generate_dek(); + assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); + + // Dummy-Slot hat korrekte Längen + let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); + assert_eq!(dummy_dek.len(), 32); + assert_eq!(dummy_nonce.len(), 12); + assert_eq!(dummy_tag.len(), 16); + assert_eq!(dummy_salt.len(), 16); + } } diff --git a/src/main.rs b/src/main.rs index c1a72c9..e68035a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,21 @@ enum Commands { /// Pfad zur zu erstellenden .sanctum Containerdatei #[arg(short, long)] path: PathBuf, + + /// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability) + #[arg(long, default_value_t = false)] + with_hidden: bool, + }, + + /// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten + Compact { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + + /// Maximale Anzahl an Seiten, die freigegeben werden sollen (Standard: alle freien Seiten) + #[arg(short, long)] + pages: Option, }, /// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein @@ -157,7 +172,7 @@ fn parse_drive_letter(s: &str) -> Result { Ok(ch.to_ascii_uppercase()) } -fn handle_init(container_path: &Path) -> Result<()> { +fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { if container_path.exists() { bail!( "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", @@ -169,65 +184,208 @@ fn handle_init(container_path: &Path) -> Result<()> { println!("│ Sanctum — Neuen verschlüsselten Container erstellen │"); println!("└─────────────────────────────────────────────────────────────┘"); println!(" Zieldatei: {}", container_path.display()); + if with_hidden { + println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault")); + } println!(); - let password = rpassword::prompt_password("Master-Passwort eingeben: ") - .context("Fehler beim Einlesen des Passworts")?; + if with_hidden { + println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───"); + let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") + .context("Fehler beim Einlesen des Passworts")?; + if password_0.trim().is_empty() { + bail!("Das Master-Passwort darf nicht leer sein."); + } + let confirm_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ") + .context("Fehler beim Einlesen der Passwort-Bestätigung")?; + if password_0 != confirm_0 { + bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!"); + } - if password.trim().is_empty() { - bail!("Das Master-Passwort darf nicht leer sein."); + println!(); + println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───"); + println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG")); + let password_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ") + .context("Fehler beim Einlesen des Passworts")?; + if password_1.trim().is_empty() { + bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein."); + } + if password_1 == password_0 { + bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!"); + } + let confirm_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ") + .context("Fehler beim Einlesen der Passwort-Bestätigung")?; + if password_1 != confirm_1 { + bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!"); + } + + println!(); + ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab..."); + let salt_0 = generate_salt(); + let kdf_params_0 = KdfParams::default(); + let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?; + + let salt_1 = generate_salt(); + let kdf_params_1 = KdfParams::default(); + let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?; + + ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG..."); + let dek_0 = generate_dek(); + let dek_1 = generate_dek(); + + ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM..."); + let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?; + let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?; + + ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus..."); + let db = Database::open(container_path) + .context("Konnte SQLite-Containerdatei nicht anlegen")?; + + db.init_schema_with_hidden( + &salt_0, + &kdf_params_0, + &wrapped_dek_0, + &nonce_0, + &tag_0, + Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_1)), + )?; + + db.checkpoint()?; + + let phrase_0 = dek_to_mnemonic(&dek_0)?; + let phrase_1 = dek_to_mnemonic(&dek_1)?; + + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + println!(" • Container: {}", container_path.display()); + println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); + println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)"); + println!(" • Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung"); + println!(" • Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie"); + println!(); + println!(" Befehl zum Einbinden als Netzlaufwerk:"); + println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); + println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)"); + println!(); + + println!(" ┌─────────────────────────────────────────────────────────┐"); + println!(" │ NOTFALLSCHLÜSSEL — SLOT 0 (STANDARD / DECOY VAULT) │"); + println!(" └─────────────────────────────────────────────────────────┘"); + ui::print_recovery_phrase_card(&phrase_0); + + println!(" ┌─────────────────────────────────────────────────────────┐"); + println!(" │ NOTFALLSCHLÜSSEL — SLOT 1 (HIDDEN VAULT) │"); + println!(" └─────────────────────────────────────────────────────────┘"); + ui::print_recovery_phrase_card(&phrase_1); + } else { + let password = rpassword::prompt_password("Master-Passwort eingeben: ") + .context("Fehler beim Einlesen des Passworts")?; + + if password.trim().is_empty() { + bail!("Das Master-Passwort darf nicht leer sein."); + } + + let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ") + .context("Fehler beim Einlesen der Passwort-Bestätigung")?; + + if password != confirm_password { + bail!("Die eingegebenen Passwörter stimmen nicht überein!"); + } + + println!(); + ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)..."); + let salt = generate_salt(); + let kdf_params = KdfParams::default(); + let kek = derive_kek(&password, &salt, &kdf_params) + .context("KDF-Schlüsselableitung fehlgeschlagen")?; + + ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG..."); + let dek = generate_dek(); + + ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM..."); + let (wrapped_dek, header_nonce, header_tag) = + wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?; + + ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus..."); + let db = Database::open(container_path) + .context("Konnte SQLite-Containerdatei nicht anlegen")?; + + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .context("Fehler bei der Schema-Initialisierung")?; + + db.checkpoint() + .context("Fehler beim finalen WAL-Checkpoint")?; + + let recovery_phrase = + dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?; + + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + println!(" • Container: {}", container_path.display()); + println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); + println!(" • KDF: Argon2id (M=64MB, T=3, P=4)"); + println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)"); + println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt"); + println!(); + println!(" Befehl zum Einbinden als Netzlaufwerk:"); + println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); + println!(); + + ui::print_recovery_phrase_card(&recovery_phrase); } - let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ") - .context("Fehler beim Einlesen der Passwort-Bestätigung")?; + Ok(()) +} - if password != confirm_password { - bail!("Die eingegebenen Passwörter stimmen nicht überein!"); +fn handle_compact(container_path: &Path, pages: Option) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); } + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Storage-Kompaktierung (Anti-Forensics) │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); println!(); - ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)..."); - let salt = generate_salt(); - let kdf_params = KdfParams::default(); - let kek = derive_kek(&password, &salt, &kdf_params) - .context("KDF-Schlüsselableitung fehlgeschlagen")?; - ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG..."); - let dek = generate_dek(); - - ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM..."); - let (wrapped_dek, header_nonce, header_tag) = - wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?; - - ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus..."); let db = Database::open(container_path) - .context("Konnte SQLite-Containerdatei nicht anlegen")?; + .context("Konnte Container-Datenbank nicht öffnen")?; - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) - .context("Fehler bei der Schema-Initialisierung")?; + let freelist_before = db.freelist_count() + .context("Konnte Freelist-Größe nicht ermitteln")?; + println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string())); + + if freelist_before == 0 { + println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig."); + println!(); + return Ok(()); + } + + ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus..."); + let freed = db.incremental_vacuum(pages) + .context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?; + + ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch..."); db.checkpoint() .context("Fehler beim finalen WAL-Checkpoint")?; - let recovery_phrase = - dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?; + let freelist_after = db.freelist_count().unwrap_or(0); println!(); println!("┌─────────────────────────────────────────────────────────────┐"); - println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │"); + println!("│ ✔ Container erfolgreich kompaktiert! │"); println!("└─────────────────────────────────────────────────────────────┘"); println!(); - println!(" • Container: {}", container_path.display()); - println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); - println!(" • KDF: Argon2id (M=64MB, T=3, P=4)"); - println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)"); + println!(" • Freigegebene Seiten: {}", ui::green(&freed.to_string())); + println!(" • Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string())); println!(); - println!(" Befehl zum Einbinden als Netzlaufwerk:"); - println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display()))); - println!(); - - ui::print_recovery_phrase_card(&recovery_phrase); - Ok(()) } @@ -248,11 +406,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; - let dek = if let Some(phrase) = recovery_key { + let (dek, target_slot_id) = if let Some(phrase) = recovery_key { ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); - d + (d, 0) } else { let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") .context("Fehler beim Einlesen des aktuellen Passworts")?; @@ -268,16 +426,22 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .context("Konnte Container-Header nicht lesen")?; ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort..."); - let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params) - .context("Schlüsselableitung fehlgeschlagen")?; + let mut found = None; + for slot in &meta.slots { + if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(d) = unwrap_dek( + &old_kek, + &slot.wrapped_dek, + &slot.header_nonce, + &slot.header_tag, + ) { + found = Some((d, slot.slot_id)); + break; + } + } + } - unwrap_dek( - &old_kek, - &meta.wrapped_dek, - &meta.header_nonce, - &meta.header_tag, - ) - .map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))? + found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))? }; println!(); @@ -306,7 +470,8 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?; ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus..."); - db.update_meta_keys( + db.update_slot_keys( + target_slot_id, &new_salt, &new_params, &new_wrapped_dek, @@ -324,6 +489,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() println!("└─────────────────────────────────────────────────────────────┘"); println!(); println!(" • Container: {}", container_path.display()); + println!(" • Vault Slot: {}", target_slot_id); println!(" • KDF: Argon2id mit frischem Salt"); println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)"); println!(); @@ -425,12 +591,23 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; - let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params) - .context("Schlüsselableitung fehlgeschlagen")?; - let dek = unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) - .map_err(|_| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; + + let mut found = None; + for slot in &meta.slots { + if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + found = Some((dek, slot.slot_id)); + break; + } + } + } + + let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; let phrase = dek_to_mnemonic(&dek)?; + if slot_id == 1 { + println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)")); + } ui::print_recovery_phrase_card(&phrase); Ok(()) @@ -453,13 +630,23 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { let dek = if !password.trim().is_empty() { let db = Database::open(container_path).context("Konnte Container nicht öffnen")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?; - let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params)?; - match unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) { - Ok(d) => { + + let mut found = None; + for slot in &meta.slots { + if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + found = Some(d); + break; + } + } + } + + match found { + Some(d) => { println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔")); Some(d) } - Err(_) => { + None => { println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); None } @@ -483,8 +670,11 @@ async fn run() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Init { path } => { - handle_init(&path)?; + Commands::Init { path, with_hidden } => { + handle_init(&path, with_hidden)?; + } + Commands::Compact { path, pages } => { + handle_compact(&path, pages)?; } Commands::Mount { path, diff --git a/src/mount.rs b/src/mount.rs index 5c14502..19a21ef 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -107,33 +107,56 @@ pub async fn mount_container( .read_meta() .context("Konnte Container-Header nicht lesen")?; - let (dek, version) = match auth { + let (dek, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); - let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params) - .context("Schlüsselableitung fehlgeschlagen")?; + let mut unwrapped = None; - ui::step(3, 4, "🔓", "Entschlüssele DEK via AES-256-GCM..."); - let dek = unwrap_dek( - &kek, - &meta.wrapped_dek, - &meta.header_nonce, - &meta.header_tag, - ) - .context("Ungültiges Master-Passwort oder Container beschädigt")?; - (dek, meta.version) + for slot in &meta.slots { + if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(dek) = unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.header_nonce, + &slot.header_tag, + ) { + unwrapped = Some((dek, slot.version, slot.slot_id)); + break; + } + } + } + + match unwrapped { + Some((dek, ver, slot_id)) => { + ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); + (dek, ver, slot_id) + } + None => { + bail!("Ungültiges Master-Passwort oder Container beschädigt"); + } + } } ContainerAuth::RecoveryKey(ref phrase) => { ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); let dek = mnemonic_to_dek(phrase) .context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); - (dek, meta.version) + + let vault_id = if db.has_hidden_vault().unwrap_or(false) { + let is_hidden = { + let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); + !children.is_empty() + }; + if is_hidden { 1 } else { 0 } + } else { + 0 + }; + (dek, meta.version, vault_id) } }; - // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield) - let fs = SanctumFs::with_options(db.clone(), dek, version, anti_leak); + // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing) + let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); let last_activity = fs.last_activity(); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) @@ -300,6 +323,9 @@ pub async fn mount_container( println!(" • Container: {}", container_path.display()); println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); + if vault_id == 1 { + println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)")); + } if let Some(secs) = idle_timeout { println!(" • Auto-Lock: Inaktivität nach {}s", secs); } @@ -350,6 +376,18 @@ pub async fn mount_container( let _ = shutdown_tx.send(true); let _ = server_handle.await; + // Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren + if let Ok(freelist) = db.freelist_count() { + if freelist > 0 { + print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist); + let _ = std::io::Write::flush(&mut std::io::stdout()); + match db.incremental_vacuum(None) { + Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed), + Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))), + } + } + } + // SQLite WAL Checkpoint erzwingen print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); let _ = std::io::Write::flush(&mut std::io::stdout()); diff --git a/src/recovery.rs b/src/recovery.rs index db47c76..b4fe1b5 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::crypto::{ derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, }; -use crate::storage::{ContainerMeta, Database}; +use crate::storage::{ContainerMeta, Database, SlotMeta}; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const CURRENT_BACKUP_VERSION: u32 = 1; @@ -79,6 +79,16 @@ impl HeaderBackup { let mut header_tag = [0u8; 16]; header_tag.copy_from_slice(&tag_bytes); + let slot0 = SlotMeta { + slot_id: 0, + version: self.container_format_version, + kdf_salt, + kdf_params: self.kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce, + header_tag, + }; + Ok(ContainerMeta { version: self.container_format_version, kdf_salt, @@ -86,6 +96,7 @@ impl HeaderBackup { wrapped_dek, header_nonce, header_tag, + slots: vec![slot0], }) } } @@ -166,6 +177,16 @@ pub fn restore_header_from_recovery_key( let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?; + let slot0 = SlotMeta { + slot_id: 0, + version: FORMAT_VERSION, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce, + header_tag, + }; + let meta = ContainerMeta { version: FORMAT_VERSION, kdf_salt: salt, @@ -173,6 +194,7 @@ pub fn restore_header_from_recovery_key( wrapped_dek, header_nonce, header_tag, + slots: vec![slot0], }; // 4. In Container schreiben diff --git a/src/storage.rs b/src/storage.rs index 08cc6fe..c97d42f 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -2,10 +2,15 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; +use rand::rngs::OsRng; +use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; -use crate::crypto::{KdfParams, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES}; +use crate::crypto::{ + decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION, + FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, +}; #[allow(dead_code)] #[derive(Debug, Clone)] @@ -29,6 +34,17 @@ pub struct ChunkRecord { pub ciphertext: Vec, } +#[derive(Debug, Clone)] +pub struct SlotMeta { + pub slot_id: u32, + pub version: u32, + pub kdf_salt: [u8; 16], + pub kdf_params: KdfParams, + pub wrapped_dek: Vec, + pub header_nonce: [u8; 12], + pub header_tag: [u8; 16], +} + #[allow(dead_code)] #[derive(Debug, Clone)] pub struct ContainerMeta { @@ -38,6 +54,7 @@ pub struct ContainerMeta { pub wrapped_dek: Vec, pub header_nonce: [u8; 12], pub header_tag: [u8; 16], + pub slots: Vec, } #[derive(Clone)] @@ -60,6 +77,7 @@ impl Database { conn: Arc::new(Mutex::new(conn)), }; db.init_pragmas()?; + db.ensure_schema_upgrades()?; Ok(db) } @@ -71,35 +89,141 @@ impl Database { conn: Arc::new(Mutex::new(conn)), }; db.init_pragmas()?; + db.ensure_schema_upgrades()?; Ok(db) } - /// Setzt die vorgeschriebenen SQLite3-Pragmas: WAL, NORMAL synchronous, 8192 Page-Size. + pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap() + } + + /// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalten slot_id und vault_id, auto_vacuum) durch. + pub fn ensure_schema_upgrades(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0); + if av != 2 { + // Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum + let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); + } + + // Spalte slot_id in meta + let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []); + + // Spalte vault_id in nodes + let _ = conn.execute("ALTER TABLE nodes ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []); + let _ = conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_vault_parent ON nodes(vault_id, parent_id)", []); + + // Spalte vault_id in chunks + let _ = conn.execute("ALTER TABLE chunks ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []); + + Ok(()) + } + + /// Setzt die vorgeschriebenen SQLite3-Pragmas: 8192 Page-Size, Incremental Auto-Vacuum, WAL, NORMAL synchronous, Secure Delete. pub fn init_pragmas(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute_batch( "PRAGMA page_size = 8192; + PRAGMA auto_vacuum = INCREMENTAL; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; + PRAGMA secure_delete = ON; PRAGMA busy_timeout = 5000;", )?; Ok(()) } - /// Initialisiert das Datenbankschema für einen neuen Container. - pub fn init_schema( + /// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben. + pub fn incremental_vacuum(&self, pages: Option) -> Result { + let conn = self.conn.lock().unwrap(); + let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0); + if before <= 0 { + return Ok(0); + } + + let pragma_sql = match pages { + Some(n) => format!("PRAGMA incremental_vacuum({});", n), + None => "PRAGMA incremental_vacuum;".to_string(), + }; + + let mut stmt = conn.prepare(&pragma_sql)?; + let mut rows = stmt.query([])?; + let mut stepped = 0; + while let Some(_) = rows.next()? { + stepped += 1; + } + drop(rows); + drop(stmt); + + let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0); + let actual_freed = (before - after).max(0) as usize; + Ok(actual_freed.max(stepped)) + } + + /// Gibt die Anzahl ungenutzter Freelist-Seiten zurück. + pub fn freelist_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0))?; + Ok(count as usize) + } + + /// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding). + pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1" + )?; + let chunks: Vec<(u32, usize)> = stmt + .query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + + for (chunk_idx, ct_len) in chunks { + let mut noise = vec![0u8; ct_len]; + let mut nonce_noise = [0u8; 12]; + let mut tag_noise = [0u8; 16]; + OsRng.fill_bytes(&mut noise); + OsRng.fill_bytes(&mut nonce_noise); + OsRng.fill_bytes(&mut tag_noise); + + let _ = conn.execute( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", + params![nonce_noise.as_slice(), tag_noise.as_slice(), noise, node_id, chunk_idx], + ); + } + Ok(()) + } + + /// Prüft, ob ein Hidden Vault (Wurzelknoten 2 mit vault_id = 1) im Container existiert. + pub fn has_hidden_vault(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let exists: bool = conn + .query_row( + "SELECT 1 FROM nodes WHERE id = 2 AND vault_id = 1 LIMIT 1", + [], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + Ok(exists) + } + + /// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault). + pub fn init_schema_with_hidden( &self, salt: &[u8; 16], kdf_params: &KdfParams, wrapped_dek: &[u8], header_nonce: &[u8; 12], header_tag: &[u8; 16], + hidden: Option<(&[u8; 16], &KdfParams, &[u8], &[u8; 12], &[u8; 16])>, ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta ( + slot_id INTEGER NOT NULL PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL, kdf_salt BLOB NOT NULL, @@ -111,6 +235,7 @@ impl Database { CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + vault_id INTEGER NOT NULL DEFAULT 0, parent_id INTEGER, name TEXT NOT NULL, is_dir INTEGER NOT NULL, @@ -119,10 +244,11 @@ impl Database { modified_at INTEGER NOT NULL, FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(COALESCE(parent_id, 0), name); + CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(vault_id, COALESCE(parent_id, 0), name); CREATE TABLE IF NOT EXISTS chunks ( node_id INTEGER NOT NULL, + vault_id INTEGER NOT NULL DEFAULT 0, chunk_index INTEGER NOT NULL, nonce BLOB NOT NULL, tag BLOB NOT NULL, @@ -132,101 +258,172 @@ impl Database { );", )?; - // Metadaten einfügen - let params_json = serde_json::to_string(kdf_params)?; + // Slot 0 einfügen (Standard / Decoy Vault) + let params_json_0 = serde_json::to_string(kdf_params)?; conn.execute( - "INSERT INTO meta (magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, salt.as_slice(), - params_json, + params_json_0, wrapped_dek, header_nonce.as_slice(), header_tag.as_slice(), ], )?; - // Root-Verzeichnis '/' mit id = 1 anlegen + // Wurzelknoten '/' für Vault 0 (id = 1, vault_id = 0) anlegen let now = current_timestamp(); conn.execute( - "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) - VALUES (1, NULL, '', 1, 0, ?1, ?2)", + "INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)", params![now, now], )?; + // Slot 1: Entweder echter Hidden Vault ODER ununterscheidbares kryptografisches Rauschen (Plausible Deniability) + if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden { + let params_json_1 = serde_json::to_string(h_params)?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + h_salt.as_slice(), + params_json_1, + h_wrapped, + h_nonce.as_slice(), + h_tag.as_slice(), + ], + )?; + + // Wurzelknoten '/' für Hidden Vault (id = 2, vault_id = 1) anlegen + conn.execute( + "INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (2, 1, NULL, '', 1, 0, ?1, ?2)", + params![now, now], + )?; + } else { + // Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie + let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); + let dummy_params_json = serde_json::to_string(&KdfParams::default())?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + dummy_salt.as_slice(), + dummy_params_json, + dummy_dek.as_slice(), + dummy_nonce.as_slice(), + dummy_tag.as_slice(), + ], + )?; + } + Ok(()) } - /// Liest die Metadaten des Containers aus der `meta`-Tabelle aus und verifiziert die Magic Bytes. - pub fn read_meta(&self) -> Result { + /// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability). + pub fn init_schema( + &self, + salt: &[u8; 16], + kdf_params: &KdfParams, + wrapped_dek: &[u8], + header_nonce: &[u8; 12], + header_tag: &[u8; 16], + ) -> Result<()> { + self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None) + } + + /// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen). + pub fn read_slots(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag FROM meta LIMIT 1" + "SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag + FROM meta ORDER BY slot_id ASC", )?; - let meta = stmt.query_row([], |row| { - let magic: Vec = row.get(0)?; + let rows = stmt.query_map([], |row| { + let slot_id: u32 = row.get(0)?; let version: u32 = row.get(1)?; - let kdf_salt: Vec = row.get(2)?; - let kdf_params_str: String = row.get(3)?; + let salt_vec: Vec = row.get(2)?; + let params_str: String = row.get(3)?; let wrapped_dek: Vec = row.get(4)?; - let header_nonce: Vec = row.get(5)?; - let header_tag: Vec = row.get(6)?; + let nonce_vec: Vec = row.get(5)?; + let tag_vec: Vec = row.get(6)?; - Ok(( - magic, + let mut kdf_salt = [0u8; 16]; + if salt_vec.len() == 16 { + kdf_salt.copy_from_slice(&salt_vec); + } + let mut header_nonce = [0u8; 12]; + if nonce_vec.len() == 12 { + header_nonce.copy_from_slice(&nonce_vec); + } + let mut header_tag = [0u8; 16]; + if tag_vec.len() == 16 { + header_tag.copy_from_slice(&tag_vec); + } + + let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default(); + + Ok(SlotMeta { + slot_id, version, kdf_salt, - kdf_params_str, + kdf_params, wrapped_dek, header_nonce, header_tag, - )) + }) })?; - if meta.0.as_slice() != MAGIC_BYTES.as_slice() { + let mut slots = Vec::new(); + for r in rows { + slots.push(r?); + } + Ok(slots) + } + + /// Liest die Metadaten des Containers (Slot 0 und alle Slots) aus der `meta`-Tabelle aus und verifiziert die Magic Bytes. + pub fn read_meta(&self) -> Result { + let slots = self.read_slots()?; + if slots.is_empty() { + bail!("Container-Header ist leer oder beschädigt"); + } + + let slot0 = &slots[0]; + let conn = self.conn.lock().unwrap(); + let magic: Vec = conn.query_row( + "SELECT magic FROM meta WHERE slot_id = 0 LIMIT 1", + [], + |r| r.get(0), + )?; + + if magic.as_slice() != MAGIC_BYTES.as_slice() { bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein"); } - if meta.1 != FORMAT_VERSION_V1 && meta.1 != FORMAT_VERSION_V2 { - bail!("Nicht unterstützte Sanctum-Formatversion: {}", meta.1); + if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 { + bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version); } - - if meta.2.len() != 16 { - bail!("Ungültige Salt-Länge im Header"); - } - let mut salt = [0u8; 16]; - salt.copy_from_slice(&meta.2); - - let kdf_params: KdfParams = serde_json::from_str(&meta.3) - .context("KDF-Parameter im Header konnten nicht deserialisiert werden")?; - - if meta.5.len() != 12 { - bail!("Ungültige Header-Nonce-Länge"); - } - let mut header_nonce = [0u8; 12]; - header_nonce.copy_from_slice(&meta.5); - - if meta.6.len() != 16 { - bail!("Ungültige Header-Tag-Länge"); - } - let mut header_tag = [0u8; 16]; - header_tag.copy_from_slice(&meta.6); - Ok(ContainerMeta { - version: meta.1, - kdf_salt: salt, - kdf_params, - wrapped_dek: meta.4, - header_nonce, - header_tag, + version: slot0.version, + kdf_salt: slot0.kdf_salt, + kdf_params: slot0.kdf_params.clone(), + wrapped_dek: slot0.wrapped_dek.clone(), + header_nonce: slot0.header_nonce, + header_tag: slot0.header_tag, + slots, }) } - /// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK im Header (Passwortänderung). + /// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK in Slot 0 (Passwortänderung). pub fn update_meta_keys( &self, new_salt: &[u8; 16], @@ -234,22 +431,36 @@ impl Database { new_wrapped_dek: &[u8], new_header_nonce: &[u8; 12], new_header_tag: &[u8; 16], + ) -> Result<()> { + self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag) + } + + /// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots. + pub fn update_slot_keys( + &self, + slot_id: u32, + new_salt: &[u8; 16], + new_params: &KdfParams, + new_wrapped_dek: &[u8], + new_header_nonce: &[u8; 12], + new_header_tag: &[u8; 16], ) -> Result<()> { let conn = self.conn.lock().unwrap(); let params_json = serde_json::to_string(new_params)?; let rows_affected = conn.execute( - "UPDATE meta SET kdf_salt = ?1, kdf_params = ?2, wrapped_dek = ?3, header_nonce = ?4, header_tag = ?5", + "UPDATE meta SET kdf_salt = ?1, kdf_params = ?2, wrapped_dek = ?3, header_nonce = ?4, header_tag = ?5 WHERE slot_id = ?6", params![ new_salt.as_slice(), params_json, new_wrapped_dek, new_header_nonce.as_slice(), new_header_tag.as_slice(), + slot_id, ], )?; if rows_affected == 0 { - bail!("Konnte Container-Header nicht aktualisieren: meta-Tabelle ist leer"); + bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id); } Ok(()) @@ -265,69 +476,150 @@ impl Database { Ok(()) } - /// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf. - pub fn resolve_path(&self, raw_path: &str) -> Result> { + /// Ermittelt die Root-Knoten-ID für einen bestimmten Vault (Vault 0 = 1, Vault 1 = 2). + pub fn get_root_node_id_for_vault(vault_id: u32) -> i64 { + if vault_id == 1 { + 2 + } else { + 1 + } + } + + /// Löst einen hierarchischen Pfad innerhalb eines bestimmten Vaults auf. + pub fn resolve_path_in_vault( + &self, + raw_path: &str, + vault_id: u32, + dek: &[u8; 32], + ) -> Result> { + let root_id = Self::get_root_node_id_for_vault(vault_id); let normalized = raw_path.trim_matches('/'); if normalized.is_empty() { - return self.get_node_by_id(1); + return self.get_node_by_id_in_vault(root_id, vault_id, dek); } let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); let conn = self.conn.lock().unwrap(); - let mut current_id = 1i64; + let mut current_id = root_id; let mut last_record = None; for (idx, segment) in segments.iter().enumerate() { - let mut stmt = conn.prepare( - "SELECT id, parent_id, name, is_dir, size, created_at, modified_at - FROM nodes - WHERE parent_id = ?1 AND name = ?2", - )?; + if vault_id == 0 { + let mut stmt = conn.prepare( + "SELECT id, parent_id, name, is_dir, size, created_at, modified_at + FROM nodes + WHERE vault_id = 0 AND parent_id = ?1 AND name = ?2", + )?; - let record: Option = stmt - .query_row(params![current_id, segment], |row| { - Ok(NodeRecord { - id: row.get(0)?, - parent_id: row.get(1)?, - name: row.get(2)?, - is_dir: row.get::<_, i32>(3)? != 0, - size: row.get::<_, i64>(4)? as u64, - created_at: row.get::<_, i64>(5)? as u64, - modified_at: row.get::<_, i64>(6)? as u64, + let record: Option = stmt + .query_row(params![current_id, segment], |row| { + Ok(NodeRecord { + id: row.get(0)?, + parent_id: row.get(1)?, + name: row.get(2)?, + is_dir: row.get::<_, i32>(3)? != 0, + size: row.get::<_, i64>(4)? as u64, + created_at: row.get::<_, i64>(5)? as u64, + modified_at: row.get::<_, i64>(6)? as u64, + }) }) - }) - .optional()?; + .optional()?; - match record { - Some(rec) => { - if idx + 1 < segments.len() && !rec.is_dir { - // Zwischenelement ist kein Verzeichnis - return Ok(None); + match record { + Some(rec) => { + if idx + 1 < segments.len() && !rec.is_dir { + return Ok(None); + } + current_id = rec.id; + last_record = Some(rec); } - current_id = rec.id; - last_record = Some(rec); + None => return Ok(None), + } + } else { + // Hidden Vault: Durchsuche Kinder des aktuellen Ordners und entschlüssele die Namen + let mut stmt = conn.prepare( + "SELECT id, parent_id, name, is_dir, size, created_at, modified_at + FROM nodes + WHERE vault_id = 1 AND parent_id = ?1", + )?; + + let rows = stmt.query_map(params![current_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i32>(3)? != 0, + row.get::<_, i64>(4)? as u64, + row.get::<_, i64>(5)? as u64, + row.get::<_, i64>(6)? as u64, + )) + })?; + + let mut matched_record = None; + for r in rows { + let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; + let dec_name = decrypt_node_name(dek, &enc_name).unwrap_or(enc_name); + if dec_name == *segment { + matched_record = Some(NodeRecord { + id, + parent_id: p_id, + name: dec_name, + is_dir, + size, + created_at: c_at, + modified_at: m_at, + }); + break; + } + } + + match matched_record { + Some(rec) => { + if idx + 1 < segments.len() && !rec.is_dir { + return Ok(None); + } + current_id = rec.id; + last_record = Some(rec); + } + None => return Ok(None), } - None => return Ok(None), } } Ok(last_record) } - pub fn get_node_by_id(&self, id: i64) -> Result> { + /// Löst einen hierarchischen Pfad im Standard-Vault (Vault 0) auf. + pub fn resolve_path(&self, raw_path: &str) -> Result> { + self.resolve_path_in_vault(raw_path, 0, &[0u8; 32]) + } + + pub fn get_node_by_id_in_vault( + &self, + id: i64, + vault_id: u32, + dek: &[u8; 32], + ) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at - FROM nodes WHERE id = ?1", + FROM nodes WHERE id = ?1 AND vault_id = ?2", )?; let record = stmt - .query_row(params![id], |row| { + .query_row(params![id, vault_id], |row| { + let enc_name: String = row.get(2)?; + let name = if vault_id == 1 { + decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) + } else { + enc_name + }; + Ok(NodeRecord { id: row.get(0)?, parent_id: row.get(1)?, - name: row.get(2)?, + name, is_dir: row.get::<_, i32>(3)? != 0, size: row.get::<_, i64>(4)? as u64, created_at: row.get::<_, i64>(5)? as u64, @@ -339,21 +631,37 @@ impl Database { Ok(record) } - /// Listet alle direkten Kinder eines Verzeichnisknotens auf. - pub fn list_children(&self, parent_id: i64) -> Result> { + pub fn get_node_by_id(&self, id: i64) -> Result> { + self.get_node_by_id_in_vault(id, 0, &[0u8; 32]) + } + + /// Listet alle direkten Kinder eines Verzeichnisknotens innerhalb eines Vaults auf. + pub fn list_children_in_vault( + &self, + parent_id: i64, + vault_id: u32, + dek: &[u8; 32], + ) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes - WHERE parent_id = ?1 - ORDER BY is_dir DESC, name ASC", + WHERE vault_id = ?1 AND parent_id = ?2 + ORDER BY is_dir DESC, id ASC", )?; - let rows = stmt.query_map(params![parent_id], |row| { + let rows = stmt.query_map(params![vault_id, parent_id], |row| { + let enc_name: String = row.get(2)?; + let name = if vault_id == 1 { + decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) + } else { + enc_name + }; + Ok(NodeRecord { id: row.get(0)?, parent_id: row.get(1)?, - name: row.get(2)?, + name, is_dir: row.get::<_, i32>(3)? != 0, size: row.get::<_, i64>(4)? as u64, created_at: row.get::<_, i64>(5)? as u64, @@ -368,20 +676,37 @@ impl Database { Ok(entries) } - /// Erstellt einen neuen Datei- oder Ordnerknoten. - pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { + /// Listet alle direkten Kinder eines Verzeichnisknotens im Standard-Vault auf. + pub fn list_children(&self, parent_id: i64) -> Result> { + self.list_children_in_vault(parent_id, 0, &[0u8; 32]) + } + + /// Erstellt einen neuen Datei- oder Ordnerknoten in einem bestimmten Vault. + pub fn create_node_in_vault( + &self, + vault_id: u32, + parent_id: i64, + name: &str, + is_dir: bool, + dek: &[u8; 32], + ) -> Result { let now = current_timestamp(); let conn = self.conn.lock().unwrap(); + let stored_name = if vault_id == 1 { + encrypt_node_name(dek, name) + } else { + name.to_string() + }; + conn.execute( - "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) - VALUES (?1, ?2, ?3, 0, ?4, ?5)", - params![parent_id, name, if is_dir { 1 } else { 0 }, now, now], + "INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", + params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now], )?; let new_id = conn.last_insert_rowid(); - // Aktualisiere das Änderungsdatum des Elternordners let _ = conn.execute( "UPDATE nodes SET modified_at = ?1 WHERE id = ?2", params![now, parent_id], @@ -398,6 +723,11 @@ impl Database { }) } + /// Erstellt einen neuen Datei- oder Ordnerknoten im Standard-Vault. + pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { + self.create_node_in_vault(0, parent_id, name, is_dir, &[0u8; 32]) + } + /// Aktualisiert Dateigröße und Modifikationszeitstempel eines Knotens. pub fn update_node_size_and_time(&self, id: i64, size: u64, modified_at: u64) -> Result<()> { let conn = self.conn.lock().unwrap(); @@ -408,26 +738,60 @@ impl Database { Ok(()) } - /// Löscht einen Knoten und alle assoziierten Chunks atomar. + /// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar. pub fn delete_node(&self, id: i64) -> Result<()> { + // 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen + let _ = self.shred_chunks_for_node(id); + + // 2. Shredde auch rekursiv alle Unterknoten + let child_ids: Vec = { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?; + let ids = stmt + .query_map(params![id], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + ids + }; + + for child_id in child_ids { + let _ = self.delete_node(child_id); + } + let conn = self.conn.lock().unwrap(); - // Foreign Key Cascade löscht Chunks und Unterordner, wir stellen es explizit sicher: conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?; conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?; Ok(()) } /// Benennt einen Knoten um und/oder verschiebt ihn in ein anderes Verzeichnis. - pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> { + pub fn rename_node_in_vault( + &self, + id: i64, + new_parent_id: i64, + new_name: &str, + vault_id: u32, + dek: &[u8; 32], + ) -> Result<()> { let now = current_timestamp(); let conn = self.conn.lock().unwrap(); + let stored_name = if vault_id == 1 { + encrypt_node_name(dek, new_name) + } else { + new_name.to_string() + }; + conn.execute( "UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4", - params![new_parent_id, new_name, now, id], + params![new_parent_id, stored_name, now, id], )?; Ok(()) } + pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> { + self.rename_node_in_vault(id, new_parent_id, new_name, 0, &[0u8; 32]) + } + /// Liest einen verschlüsselten Chunk aus der Datenbank. pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result> { let conn = self.conn.lock().unwrap(); @@ -491,13 +855,49 @@ impl Database { Ok(()) } - /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei). + /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei) + /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1 AND chunk_index > ?2", + )?; + let chunks_to_shred: Vec<(u32, usize)> = stmt + .query_map(params![node_id, max_chunk_index], |row| { + Ok((row.get(0)?, row.get::<_, usize>(1)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + + let mut update_stmt = tx.prepare( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", + )?; + + for (idx, len) in chunks_to_shred { + let mut dummy_nonce = [0u8; 12]; + let mut dummy_tag = [0u8; 16]; + let mut dummy_payload = vec![0u8; len]; + OsRng.fill_bytes(&mut dummy_nonce); + OsRng.fill_bytes(&mut dummy_tag); + OsRng.fill_bytes(&mut dummy_payload); + + let _ = update_stmt.execute(params![ + dummy_nonce.as_slice(), + dummy_tag.as_slice(), + dummy_payload.as_slice(), + node_id, + idx + ]); + } + } + + tx.execute( "DELETE FROM chunks WHERE node_id = ?1 AND chunk_index > ?2", params![node_id, max_chunk_index], )?; + tx.commit()?; Ok(()) } @@ -511,10 +911,10 @@ impl Database { /// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung). pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> { let conn = self.conn.lock().unwrap(); - let params_json = serde_json::to_string(&meta.kdf_params)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta ( + slot_id INTEGER PRIMARY KEY DEFAULT 0, magic BLOB NOT NULL, version INTEGER NOT NULL, kdf_salt BLOB NOT NULL, @@ -526,19 +926,58 @@ impl Database { )?; conn.execute("DELETE FROM meta", [])?; - conn.execute( - "INSERT INTO meta (magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - MAGIC_BYTES.as_slice(), - meta.version, - meta.kdf_salt.as_slice(), - params_json, - meta.wrapped_dek, - meta.header_nonce.as_slice(), - meta.header_tag.as_slice(), - ], - )?; + + if !meta.slots.is_empty() { + for slot in &meta.slots { + let params_json = serde_json::to_string(&slot.kdf_params)?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + slot.slot_id, + MAGIC_BYTES.as_slice(), + slot.version, + slot.kdf_salt.as_slice(), + params_json, + slot.wrapped_dek, + slot.header_nonce.as_slice(), + slot.header_tag.as_slice(), + ], + )?; + } + } else { + let params_json = serde_json::to_string(&meta.kdf_params)?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + meta.version, + meta.kdf_salt.as_slice(), + params_json, + meta.wrapped_dek, + meta.header_nonce.as_slice(), + meta.header_tag.as_slice(), + ], + )?; + + // Dummy-Slot 1 für Plausible Deniability generieren + let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); + let dummy_params_json = serde_json::to_string(&KdfParams::default())?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + dummy_salt.as_slice(), + dummy_params_json, + dummy_dek.as_slice(), + dummy_nonce.as_slice(), + dummy_tag.as_slice(), + ], + )?; + } Ok(()) } @@ -695,4 +1134,155 @@ mod tests { assert!(deleted_res.is_none()); assert!(db.read_chunk(file.id, 0).unwrap().is_none()); } + + #[test] + fn test_storage_compaction_and_incremental_vacuum() { + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join(format!("compact_test_{}.sanctum", std::process::id())); + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + let db = Database::open(&db_path).unwrap(); + + let salt = [1u8; 16]; + let kdf_params = KdfParams::default(); + let wrapped_dek = vec![2u8; 32]; + let nonce = [3u8; 12]; + let tag = [4u8; 16]; + + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + + let root = db.resolve_path("/").unwrap().expect("Root node"); + let file = db.create_node(root.id, "large_file.bin", false).unwrap(); + + // 20 Chunks à 64 KB schreiben, um SQLite Seiten zuzuweisen + let payload = vec![0x42u8; 64 * 1024]; + let c_nonce = [5u8; 12]; + let c_tag = [6u8; 16]; + for i in 0..20 { + db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap(); + } + db.checkpoint().unwrap(); + + // Datei löschen -> Chunks werden geschreddert und Seiten wandern in Freelist + db.delete_node(file.id).unwrap(); + db.checkpoint().unwrap(); + + let freelist_before = db.freelist_count().unwrap(); + assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten"); + + // Incremental Vacuum ausführen + let freed = db.incremental_vacuum(None).unwrap(); + assert!(freed > 0, "Es sollten Seiten freigegeben werden"); + assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden"); + + let freelist_after = db.freelist_count().unwrap(); + assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein"); + + let _ = std::fs::remove_file(&db_path); + } + + #[test] + fn test_cryptographic_chunk_shredding() { + let db = Database::open_in_memory().unwrap(); + let salt = [1u8; 16]; + let kdf_params = KdfParams::default(); + let wrapped_dek = vec![2u8; 32]; + let nonce = [3u8; 12]; + let tag = [4u8; 16]; + + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + let root = db.resolve_path("/").unwrap().expect("Root"); + let file = db.create_node(root.id, "sensitive.dat", false).unwrap(); + + let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT"; + let c_nonce = [10u8; 12]; + let c_tag = [11u8; 16]; + db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap(); + + // Shredde Chunks + db.shred_chunks_for_node(file.id).unwrap(); + + // Prüfe, was sich in der Chunks-Tabelle befindet + let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch"); + assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!"); + assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein"); + assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein"); + assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein"); + } + + #[test] + fn test_hidden_vault_isolation_and_filename_encryption() { + let db = Database::open_in_memory().unwrap(); + let salt0 = [1u8; 16]; + let kdf_params0 = KdfParams::default(); + let wrapped_dek0 = vec![10u8; 32]; + let nonce0 = [11u8; 12]; + let tag0 = [12u8; 16]; + + let salt1 = [2u8; 16]; + let kdf_params1 = KdfParams::default(); + let wrapped_dek1 = vec![20u8; 32]; + let nonce1 = [21u8; 12]; + let tag1 = [22u8; 16]; + + let dek0 = [0xAAu8; 32]; + let dek1 = [0xBBu8; 32]; + + db.init_schema_with_hidden( + &salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0, + Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), + ).unwrap(); + + assert!(db.has_hidden_vault().unwrap()); + + // Slots prüfen + let slots = db.read_slots().unwrap(); + assert_eq!(slots.len(), 2); + assert_eq!(slots[0].slot_id, 0); + assert_eq!(slots[1].slot_id, 1); + + // Datei in Vault 0 (Decoy) erstellen + let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0"); + assert_eq!(root0.id, 1); + let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap(); + + // Datei in Vault 1 (Hidden) erstellen + let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1"); + assert_eq!(root1.id, 2); + let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap(); + + // Auflösen in Vault 0: Sieht nur public_recipe.txt + let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap(); + assert!(res_decoy.is_some()); + assert_eq!(res_decoy.unwrap().id, decoy_file.id); + + let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap(); + assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!"); + + // Auflösen in Vault 1: Sieht nur classified_leak.pdf + let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap(); + assert!(res_hidden.is_some()); + assert_eq!(res_hidden.unwrap().id, hidden_file.id); + + let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap(); + assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!"); + + // Forensische Prüfung: Roh-Inspektion der SQLite-Tabelle `nodes` + let conn = db.conn.lock().unwrap(); + let raw_name_v0: String = conn.query_row( + "SELECT name FROM nodes WHERE id = ?1", + params![decoy_file.id], + |r| r.get(0), + ).unwrap(); + assert_eq!(raw_name_v0, "public_recipe.txt"); + + let raw_name_v1: String = conn.query_row( + "SELECT name FROM nodes WHERE id = ?1", + params![hidden_file.id], + |r| r.get(0), + ).unwrap(); + assert!(raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname muss verschlüsselt mit $h$ beginnen"); + assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); + } } diff --git a/src/ui.rs b/src/ui.rs index 9ba5e4b..15406d7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -92,6 +92,14 @@ pub fn red(s: &str) -> String { } } +pub fn magenta(s: &str) -> String { + if is_vt_enabled() { + format!("\x1b[1;35m{s}\x1b[0m") + } else { + s.to_string() + } +} + /// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...` pub fn step(num: u8, total: u8, icon: &str, msg: &str) { let tag = cyan(&format!("[{}/{}]", num, total)); diff --git a/src/vfs.rs b/src/vfs.rs index 00103e3..3bd6b66 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -388,12 +388,13 @@ pub struct SanctumFs { dek: Arc>, format_version: u32, anti_leak: bool, + vault_id: u32, last_activity: Arc, } impl SanctumFs { pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self { - Self::with_options(db, dek, format_version, true) + Self::with_vault(db, dek, format_version, true, 0) } pub fn with_options( @@ -401,6 +402,16 @@ impl SanctumFs { dek: Zeroizing<[u8; 32]>, format_version: u32, anti_leak: bool, + ) -> Self { + Self::with_vault(db, dek, format_version, anti_leak, 0) + } + + pub fn with_vault( + db: Database, + dek: Zeroizing<[u8; 32]>, + format_version: u32, + anti_leak: bool, + vault_id: u32, ) -> Self { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -411,10 +422,15 @@ impl SanctumFs { dek: Arc::new(dek), format_version, anti_leak, + vault_id, last_activity: Arc::new(AtomicU64::new(now)), } } + pub fn vault_id(&self) -> u32 { + self.vault_id + } + pub fn last_activity(&self) -> Arc { self.last_activity.clone() } @@ -442,6 +458,33 @@ impl SanctumFs { None => ("", trimmed), } } + + fn resolve_path(&self, path: &str) -> Result, FsError> { + self.db + .resolve_path_in_vault(path, self.vault_id, &self.dek) + .map_err(|_| FsError::GeneralFailure) + } + + fn list_children(&self, parent_id: i64) -> Result, FsError> { + self.db + .list_children_in_vault(parent_id, self.vault_id, &self.dek) + .map_err(|_| FsError::GeneralFailure) + } + + fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { + self.db + .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) + .map_err(|e| { + error!("Fehler beim Erstellen des Knotens '{}': {e}", name); + FsError::GeneralFailure + }) + } + + fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { + self.db + .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) + .map_err(|_| FsError::GeneralFailure) + } } impl DavFileSystem for SanctumFs { @@ -473,10 +516,7 @@ impl DavFileSystem for SanctumFs { self.touch(); debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options); - let existing_node = self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)?; + let existing_node = self.resolve_path(&path_str)?; let node = match existing_node { Some(n) => { @@ -511,21 +551,14 @@ impl DavFileSystem for SanctumFs { None => { if options.create || options.create_new { let parent = self - .db - .resolve_path(parent_path) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(parent_path)? .ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); } - self.db - .create_node(parent.id, file_name, false) - .map_err(|e| { - error!("Fehler beim Erstellen der Datei: {e}"); - FsError::GeneralFailure - })? + self.create_node(parent.id, file_name, false)? } else { return Err(FsError::NotFound); } @@ -552,19 +585,14 @@ impl DavFileSystem for SanctumFs { self.touch(); let path_str = Self::path_to_str(path); let node = self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&path_str)? .ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); } - let children = self - .db - .list_children(node.id) - .map_err(|_| FsError::GeneralFailure)?; + let children = self.list_children(node.id)?; let entries: Vec, FsError>> = children .into_iter() @@ -593,9 +621,7 @@ impl DavFileSystem for SanctumFs { self.touch(); } let node = self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&path_str)? .ok_or(FsError::NotFound)?; let meta = SanctumMetaData { @@ -623,28 +649,19 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Forbidden); } - if self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)? - .is_some() - { + if self.resolve_path(&path_str)?.is_some() { return Err(FsError::Exists); } let parent = self - .db - .resolve_path(parent_path) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(parent_path)? .ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); } - self.db - .create_node(parent.id, dir_name, true) - .map_err(|_| FsError::GeneralFailure)?; + self.create_node(parent.id, dir_name, true)?; Ok(()) }) @@ -655,15 +672,14 @@ impl DavFileSystem for SanctumFs { self.touch(); let path_str = Self::path_to_str(path); let node = self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&path_str)? .ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); } - if node.id == 1 { + let root_id = Database::get_root_node_id_for_vault(self.vault_id); + if node.id == root_id { // Root-Verzeichnis darf nicht gelöscht werden return Err(FsError::Forbidden); } @@ -681,9 +697,7 @@ impl DavFileSystem for SanctumFs { self.touch(); let path_str = Self::path_to_str(path); let node = self - .db - .resolve_path(&path_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&path_str)? .ok_or(FsError::NotFound)?; if node.is_dir { @@ -709,9 +723,7 @@ impl DavFileSystem for SanctumFs { let to_str = Self::path_to_str(to); let node = self - .db - .resolve_path(&from_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&from_str)? .ok_or(FsError::NotFound)?; let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); @@ -721,9 +733,7 @@ impl DavFileSystem for SanctumFs { } let to_parent = self - .db - .resolve_path(to_parent_path) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(to_parent_path)? .ok_or(FsError::NotFound)?; if !to_parent.is_dir { @@ -731,11 +741,7 @@ impl DavFileSystem for SanctumFs { } // Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen - if let Some(dest) = self - .db - .resolve_path(&to_str) - .map_err(|_| FsError::GeneralFailure)? - { + if let Some(dest) = self.resolve_path(&to_str)? { if dest.is_dir { return Err(FsError::Forbidden); } @@ -744,9 +750,7 @@ impl DavFileSystem for SanctumFs { .map_err(|_| FsError::GeneralFailure)?; } - self.db - .rename_node(node.id, to_parent.id, to_name) - .map_err(|_| FsError::GeneralFailure)?; + self.rename_node(node.id, to_parent.id, to_name)?; Ok(()) }) @@ -763,9 +767,7 @@ impl DavFileSystem for SanctumFs { let to_str = Self::path_to_str(to); let node = self - .db - .resolve_path(&from_str) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(&from_str)? .ok_or(FsError::NotFound)?; if node.is_dir { @@ -779,15 +781,10 @@ impl DavFileSystem for SanctumFs { } let to_parent = self - .db - .resolve_path(to_parent_path) - .map_err(|_| FsError::GeneralFailure)? + .resolve_path(to_parent_path)? .ok_or(FsError::NotFound)?; - let dest_node = self - .db - .create_node(to_parent.id, to_name, false) - .map_err(|_| FsError::GeneralFailure)?; + let dest_node = self.create_node(to_parent.id, to_name, false)?; // Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!) let total_chunks = if node.size == 0 { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9a1613c..36a0753 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -609,5 +609,189 @@ async fn test_anti_leak_and_inactivity_shield() { let _ = std::fs::remove_file(&container_path); } +#[tokio::test] +async fn test_hidden_vault_and_storage_compaction_integration() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = temp_dir.join(format!("test_hidden_{}.sanctum", std::process::id())); + + if container_path.exists() { + let _ = std::fs::remove_file(&container_path); + } + + let password_decoy = "DecoyOuterPassword2026!"; + let password_hidden = "TopSecretHiddenPassword2026!"; + + // 1. Dual-Vault Initialisierung (Slot 0 = Decoy, Slot 1 = Hidden Vault) + let salt0 = generate_salt(); + let salt1 = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + + let kek0 = derive_kek(password_decoy, &salt0, &kdf_params).expect("KEK 0"); + let kek1 = derive_kek(password_hidden, &salt1, &kdf_params).expect("KEK 1"); + + let dek0 = generate_dek(); + let dek1 = generate_dek(); + + let (wrapped_dek0, nonce0, tag0) = wrap_dek(&kek0, &dek0).expect("Wrap DEK 0"); + let (wrapped_dek1, nonce1, tag1) = wrap_dek(&kek1, &dek1).expect("Wrap DEK 1"); + + let db = Database::open(&container_path).expect("Open container"); + db.init_schema_with_hidden( + &salt0, + &kdf_params, + &wrapped_dek0, + &nonce0, + &tag0, + Some((&salt1, &kdf_params, &wrapped_dek1, &nonce1, &tag1)), + ) + .expect("Init schema with hidden"); + db.checkpoint().expect("Checkpoint init"); + + // 2. Multi-Slot Authentifizierungsprüfung + let meta = db.read_meta().expect("Read meta"); + assert_eq!(meta.slots.len(), 2, "Es müssen 2 Slots initialisiert sein"); + + // Decoy Passwort entsperrt Slot 0 + let mut auth_decoy = None; + for slot in &meta.slots { + if let Ok(kek) = derive_kek(password_decoy, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + auth_decoy = Some((dek, slot.slot_id)); + break; + } + } + } + let (unwrapped_dek0, slot_id0) = auth_decoy.expect("Decoy password must unwrap"); + assert_eq!(slot_id0, 0); + assert_eq!(*unwrapped_dek0, *dek0); + + // Hidden Passwort entsperrt Slot 1 + let mut auth_hidden = None; + for slot in &meta.slots { + if let Ok(kek) = derive_kek(password_hidden, &slot.kdf_salt, &slot.kdf_params) { + if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + auth_hidden = Some((dek, slot.slot_id)); + break; + } + } + } + let (unwrapped_dek1, slot_id1) = auth_hidden.expect("Hidden password must unwrap"); + assert_eq!(slot_id1, 1); + assert_eq!(*unwrapped_dek1, *dek1); + + // 3. VFS Operationen im Decoy-Vault (Slot 0) + let fs_decoy = SanctumFs::with_vault(db.clone(), unwrapped_dek0, meta.version, true, 0); + let decoy_file_path = DavPath::new("/harmless_recipe.txt").unwrap(); + + let mut opts_write = OpenOptions::default(); + opts_write.write = true; + opts_write.create_new = true; + let mut df = fs_decoy.open(&decoy_file_path, opts_write).await.expect("Open decoy file"); + df.write_bytes(Bytes::from_static(b"Apples, Flour, Sugar, Butter")) + .await + .expect("Write decoy"); + df.flush().await.expect("Flush decoy"); + drop(df); + + // 4. VFS Operationen im Hidden-Vault (Slot 1) + let fs_hidden = SanctumFs::with_vault(db.clone(), unwrapped_dek1, meta.version, true, 1); + let hidden_file_path = DavPath::new("/classified_report.pdf").unwrap(); + + let mut opts_write2 = OpenOptions::default(); + opts_write2.write = true; + opts_write2.create_new = true; + let mut hf = fs_hidden.open(&hidden_file_path, opts_write2).await.expect("Open hidden file"); + hf.write_bytes(Bytes::from_static(b"TOP SECRET INTELLIGENCE DATA")) + .await + .expect("Write hidden"); + hf.flush().await.expect("Flush hidden"); + drop(hf); + + // 5. Strikte Isolierung verifizieren: Decoy-Vault sieht NICHTS vom Hidden-Vault + let mut stream_decoy = fs_decoy + .read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None) + .await + .expect("read_dir decoy"); + let mut decoy_entries = Vec::new(); + while let Some(Ok(entry)) = stream_decoy.next().await { + decoy_entries.push(String::from_utf8_lossy(&entry.name()).to_string()); + } + assert_eq!(decoy_entries, vec!["harmless_recipe.txt"]); + assert!(fs_decoy.metadata(&hidden_file_path).await.is_err(), "Decoy darf classified_report nicht sehen"); + + // Hidden-Vault sieht ebenfalls nur seine eigenen Dateien + let mut stream_hidden = fs_hidden + .read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None) + .await + .expect("read_dir hidden"); + let mut hidden_entries = Vec::new(); + while let Some(Ok(entry)) = stream_hidden.next().await { + hidden_entries.push(String::from_utf8_lossy(&entry.name()).to_string()); + } + assert_eq!(hidden_entries, vec!["classified_report.pdf"]); + assert!(fs_hidden.metadata(&decoy_file_path).await.is_err(), "Hidden darf harmless_recipe nicht auflösen"); + + // 6. Forensische Dateiprüfung (Anti-Forensics / Plausible Deniability) + db.checkpoint().expect("Checkpoint before raw inspection"); + let raw_bytes = std::fs::read(&container_path).expect("Read container raw bytes"); + + // Der Dateiname "classified_report.pdf" darf NIRGENDS im Rohformat im Container stehen + let needle_filename = b"classified_report"; + assert!( + !raw_bytes.windows(needle_filename.len()).any(|w| w == needle_filename), + "Forensischer Leak: Dateiname des Hidden Vaults taucht als Klartext in der Datei auf!" + ); + + let needle_payload = b"TOP SECRET INTELLIGENCE DATA"; + assert!( + !raw_bytes.windows(needle_payload.len()).any(|w| w == needle_payload), + "Forensischer Leak: Nutzlast des Hidden Vaults taucht als Klartext auf!" + ); + + // 7. Storage Compaction (Incremental Vacuum & Chunk Shredding) + // Große temporäre Datei im Hidden Vault anlegen + let temp_large_path = DavPath::new("/large_dump.dat").unwrap(); + let mut opts_dump = OpenOptions::default(); + opts_dump.write = true; + opts_dump.create_new = true; + let mut dump_file = fs_hidden.open(&temp_large_path, opts_dump).await.expect("Open dump"); + let mut large_buffer = vec![0u8; 512 * 1024]; // 512 KB echte Zufallsdaten (Shannon-Entropie 8.0) + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut large_buffer); + dump_file.write_bytes(Bytes::copy_from_slice(&large_buffer)).await.expect("Write dump"); + dump_file.flush().await.expect("Flush dump"); + drop(dump_file); + + db.checkpoint().expect("Checkpoint after write"); + + // Datei löschen (löst automatisches Chunk-Shredding aus) + fs_hidden.remove_file(&temp_large_path).await.expect("Remove dump"); + db.checkpoint().expect("Checkpoint after remove"); + + let free_pages = db.freelist_count().expect("freelist count"); + assert!(free_pages > 0, "Nach dem Löschen müssen freie Seiten in der Freelist existieren"); + + // Incremental Vacuum ausführen + let reclaimed = db.incremental_vacuum(None).expect("incremental vacuum"); + assert!(reclaimed > 0, "Seiten müssen an das Dateisystem zurückgegeben werden"); + assert_eq!(db.freelist_count().unwrap(), 0, "Freelist muss jetzt 0 sein"); + + // Verifiziere, dass verbleibende Dateien in beiden Vaults intakt lesbar sind + let mut r_decoy = fs_decoy.open(&decoy_file_path, OpenOptions::default()).await.expect("open decoy"); + let d_data = r_decoy.read_bytes(30).await.expect("read decoy"); + assert_eq!(&d_data[..], b"Apples, Flour, Sugar, Butter"); + + let mut r_hidden = fs_hidden.open(&hidden_file_path, OpenOptions::default()).await.expect("open hidden"); + let h_data = r_hidden.read_bytes(30).await.expect("read hidden"); + assert_eq!(&h_data[..], b"TOP SECRET INTELLIGENCE DATA"); + + // Aufräumen + let _ = std::fs::remove_file(&container_path); +} + +