From 541190cff4960ca1b9cfea21ea517c3ebaeca9a7 Mon Sep 17 00:00:00 2001 From: harald Date: Thu, 10 Sep 2026 13:27:01 +0200 Subject: [PATCH] feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection - HF-01: eliminate visual leaks between decoy and hidden vaults during mount - HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history - HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions - HF-04: add --stealth mode for silent mounting in high-risk environments - VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault --- src/crypto.rs | 148 +++++++++++++++++++++++++++++++-- src/main.rs | 109 +++++++++++++++++++------ src/mount.rs | 218 ++++++++++++++++++++++++++++--------------------- src/storage.rs | 18 ++++ src/vfs.rs | 100 +++++++++++++++++++++++ 5 files changed, 464 insertions(+), 129 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index f50539f..23388c1 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -320,16 +320,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result { Ok(mnemonic.to_string()) } -/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. -/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme. -pub fn mnemonic_to_dek(phrase: &str) -> Result> { - let cleaned = phrase - .split_whitespace() - .collect::>() - .join(" "); +pub fn levenshtein_distance(a: &str, b: &str) -> usize { + let a_chars: Vec = a.chars().collect(); + let b_chars: Vec = b.chars().collect(); + let (m, n) = (a_chars.len(), b_chars.len()); - let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned) - .map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?; + let mut dp = vec![vec![0usize; n + 1]; m + 1]; + for i in 0..=m { + dp[i][0] = i; + } + for j in 0..=n { + dp[0][j] = j; + } + + for i in 1..=m { + for j in 1..=n { + let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; + dp[i][j] = (dp[i - 1][j] + 1) + .min(dp[i][j - 1] + 1) + .min(dp[i - 1][j - 1] + cost); + } + } + + dp[m][n] +} + +/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort. +pub fn suggest_bip39_word(word: &str) -> Option<&'static str> { + let word_list = bip39::Language::English.word_list(); + let mut best_match = None; + let mut min_dist = usize::MAX; + + for &valid in word_list { + let dist = levenshtein_distance(word, valid); + if dist < min_dist { + min_dist = dist; + best_match = Some(valid); + } + } + + // Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2) + if min_dist <= 2 { + best_match + } else { + None + } +} + +/// Normalisiert eine eingegebene BIP-39 Phrase: +/// - Wandelt in Kleinbuchstaben um (to_lowercase) +/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen +/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing) +pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec { + let cleaned: String = phrase + .chars() + .map(|c| { + if c.is_alphabetic() { + c.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect(); + + cleaned + .split_whitespace() + .map(|s| s.to_string()) + .collect() +} + +/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. +/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose. +pub fn mnemonic_to_dek(phrase: &str) -> Result> { + let words = normalize_mnemonic_phrase(phrase); + + if words.is_empty() { + bail!("Die Notfallphrase darf nicht leer sein."); + } + + if words.len() != 24 { + bail!( + "Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.", + words.len() + ); + } + + let word_list = bip39::Language::English.word_list(); + let mut invalid_words = Vec::new(); + + for (idx, word) in words.iter().enumerate() { + if !word_list.contains(&word.as_str()) { + let suggestion = suggest_bip39_word(word); + let msg = if let Some(sug) = suggestion { + format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug) + } else { + format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word) + }; + invalid_words.push(msg); + } + } + + if !invalid_words.is_empty() { + bail!( + "Ungültige Wörter in der Notfallphrase festgestellt:\n • {}", + invalid_words.join("\n • ") + ); + } + + let normalized_phrase = words.join(" "); + let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase) + .map_err(|_| { + anyhow::anyhow!( + "Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \ + Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort." + ) + })?; let entropy = mnemonic.to_entropy(); if entropy.len() != 32 { @@ -625,6 +730,31 @@ mod tests { assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); } + #[test] + fn test_bip39_advanced_normalisation_and_suggestions() { + let dek = generate_dek(); + let mnemonic_str = dek_to_mnemonic(&dek).unwrap(); + let words: Vec<&str> = mnemonic_str.split_whitespace().collect(); + + // 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization) + let uppercase_phrase = mnemonic_str.to_uppercase(); + let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase"); + assert_eq!(*dek, *recovered); + + // 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern) + let punctuated = format!("{}.", words.join(", ")); + let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated"); + assert_eq!(*dek, *recovered_punct); + + // 3. Typo-Vorschlag (Levenshtein) + let mut typo_words = words.clone(); + typo_words[5] = "abondon"; // Typo for abandon + let typo_phrase = typo_words.join(" "); + let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string(); + assert!(err.contains("Wort #6 'abondon' ist ungültig")); + assert!(err.contains("Meinten Sie 'abandon'?")); + } + #[test] fn test_hidden_node_name_encryption_and_dummy_slot() { let dek = generate_dek(); diff --git a/src/main.rs b/src/main.rs index febc0e8..c1c368a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,8 +76,10 @@ enum Commands { #[arg(long)] port: Option, - /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage) - #[arg(long)] + /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage). + /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel sicher und interaktiv + /// einzugeben (verhindert Klartext-Einträge in der PowerShell-Historie). + #[arg(long, num_args = 0..=1, default_missing_value = "")] recovery_key: Option, /// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer @@ -99,6 +101,10 @@ enum Commands { /// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini) #[arg(long, default_value_t = false)] no_anti_leak: bool, + + /// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben) + #[arg(short, long, default_value_t = false)] + stealth: bool, }, /// Trennt ein eingebundenes Netzlaufwerk manuell @@ -114,8 +120,9 @@ enum Commands { #[arg(short, long)] path: PathBuf, - /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort) - #[arg(long)] + /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort). + /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben. + #[arg(long, num_args = 0..=1, default_missing_value = "")] recovery_key: Option, }, @@ -162,8 +169,9 @@ enum Commands { #[arg(long)] header_file: Option, - /// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort - #[arg(long)] + /// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort. + /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben. + #[arg(long, num_args = 0..=1, default_missing_value = "")] recovery_key: Option, /// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0) @@ -228,6 +236,19 @@ pub fn parse_size_string(s: &str) -> Result { } } +/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein, +/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen. +fn prompt_recovery_key_interactive() -> Result> { + println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan("ℹ")); + println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der PowerShell-Historie.", ui::dim("OpSec")); + let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ") + .context("Fehler beim Einlesen des Notfallschlüssels")?; + if phrase.trim().is_empty() { + bail!("Der Notfallschlüssel darf nicht leer sein."); + } + Ok(Zeroizing::new(phrase)) +} + fn handle_init( container_path: &Path, with_hidden: bool, @@ -506,8 +527,13 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .context("Konnte Container-Datenbank nicht öffnen")?; let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key { + let actual_phrase = if phrase.trim().is_empty() { + prompt_recovery_key_interactive()? + } else { + Zeroizing::new(phrase.to_string()) + }; ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); - let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; + let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); (d, 0, None, None) } else { @@ -717,7 +743,37 @@ fn handle_restore_header( ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!"); println!(); println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")); - } else if let Some(key) = recovery_key { + } else { + let key_str = if let Some(key) = recovery_key { + if key.trim().is_empty() { + prompt_recovery_key_interactive()? + } else { + Zeroizing::new(key.to_string()) + } + } else { + // Interaktiver Auswahldialog statt Fehlermeldung + println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben."); + println!(" Bitte wählen Sie die gewünschte Wiederherstellungsmethode:"); + println!(" [1] Sicherungsdatei verwenden (.sanctum.hdr)"); + println!(" [2] 24-Wort BIP-39 Notfallschlüssel interaktiv eingeben"); + print!(" Auswahl [1/2]: "); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let mut choice = String::new(); + std::io::stdin().read_line(&mut choice)?; + match choice.trim() { + "1" => { + print!(" Pfad zur .sanctum.hdr Sicherungsdatei: "); + let _ = std::io::Write::flush(&mut std::io::stdout()); + let mut p = String::new(); + std::io::stdin().read_line(&mut p)?; + let hdr_path = PathBuf::from(p.trim()); + return handle_restore_header(container_path, Some(&hdr_path), None, slot); + } + "2" => prompt_recovery_key_interactive()?, + _ => bail!("Ungültige Auswahl. Wiederherstellung abgebrochen."), + } + }; + println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot); let new_password = Zeroizing::new( rpassword::prompt_password("Neues Master-Passwort festlegen: ") @@ -738,12 +794,10 @@ fn handle_restore_header( } ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); - restore_slot_from_recovery_key(container_path, key, &new_password, slot)?; + restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?; ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot)); println!(); println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")); - } else { - bail!("Bitte geben Sie entweder --header-file oder --recovery-key \"<24 WÖRTER>\" an."); } println!(); @@ -774,12 +828,8 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { .authenticate(&password) .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; let dek = keys.dek().clone(); - let slot_id = keys.slot_id(); 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(()) @@ -807,9 +857,7 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { match meta.authenticate(&password) { Some(keys) => { - let slot_id = keys.slot_id(); - let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" }; - println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc)); + println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔")); Some(keys.dek().clone()) } None => { @@ -863,21 +911,29 @@ async fn run() -> Result<()> { idle_timeout, no_screen_lock, no_anti_leak, + stealth, } => { let drive_char = match drive { Some(ref d) => parse_drive_letter(d)?, None => { let auto_drive = sanctum::windows::find_next_available_drive()?; - println!( - " {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:", - ui::cyan("ℹ"), - auto_drive - ); + if !stealth { + println!( + " {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:", + ui::cyan("ℹ"), + auto_drive + ); + } auto_drive } }; let auth = if let Some(key) = recovery_key { - ContainerAuth::RecoveryKey(Zeroizing::new(key)) + let actual_key = if key.trim().is_empty() { + prompt_recovery_key_interactive()? + } else { + Zeroizing::new(key) + }; + ContainerAuth::RecoveryKey(actual_key) } else { let prompt_text = format!( "Master-Passwort für Container '{}' eingeben: ", @@ -890,8 +946,8 @@ async fn run() -> Result<()> { ContainerAuth::Password(password) }; - let open_explorer = !no_open; - let enable_tray = !no_tray; + let open_explorer = !no_open && !stealth; + let enable_tray = !no_tray && !stealth; let lock_on_screen_lock = !no_screen_lock; let anti_leak = !no_anti_leak; mount_container( @@ -904,6 +960,7 @@ async fn run() -> Result<()> { idle_timeout, lock_on_screen_lock, anti_leak, + stealth, ) .await?; } diff --git a/src/mount.rs b/src/mount.rs index c12a669..3dc10ae 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -120,6 +120,7 @@ pub async fn mount_container( idle_timeout: Option, lock_on_screen_lock: bool, anti_leak: bool, + stealth: bool, ) -> Result<()> { let drive_str = format_drive(drive_letter); @@ -130,8 +131,10 @@ pub async fn mount_container( ); } - println!(); - ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); + if !stealth { + println!(); + ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); + } let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; @@ -141,10 +144,14 @@ pub async fn mount_container( let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { - ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); + if !stealth { + ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); + } match meta.authenticate(password) { Some(keys) => { - ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); + if !stealth { + ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); + } let vault_id = keys.slot_id(); let ver = keys.version(); let c_dek = keys.carrier_dek(); @@ -157,10 +164,14 @@ pub async fn mount_container( } } ContainerAuth::RecoveryKey(ref phrase) => { - ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); + if !stealth { + 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!"); + if !stealth { + ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); + } // Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann let is_hidden = { @@ -212,15 +223,17 @@ pub async fn mount_container( let bound_addr = listener.local_addr()?; let bound_port = bound_addr.port(); - ui::step( - 4, - 4, - "🌐", - &format!( - "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", - bound_port, drive_str - ), - ); + if !stealth { + ui::step( + 4, + 4, + "🌐", + &format!( + "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", + bound_port, drive_str + ), + ); + } let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -239,17 +252,9 @@ pub async fn mount_container( return Err(e); } - // Optional automatisch im Windows Explorer öffnen (mit DFIR ShellBag-Schutz für Hidden Vault) + // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault) if open_explorer { - if vault_id == 1 { - println!( - " {} {}", - ui::yellow("[!]"), - ui::dim("OpSec-Schutz: Explorer-Auto-Open für Hidden Vault unterdrückt (verhindert persistente ShellBag-Spuren in UsrClass.dat).") - ); - } else { - let _ = crate::windows::open_in_explorer(drive_letter); - } + let _ = crate::windows::open_in_explorer(drive_letter); } // System-Tray Initialisierung @@ -324,32 +329,33 @@ pub async fn mount_container( None }; - println!(); - println!("┌─────────────────────────────────────────────────────────────┐"); - println!("│ ✔ Sanctum Container erfolgreich gemountet │"); - println!("└─────────────────────────────────────────────────────────────┘"); - println!(); - println!(" • Container: {}", container_path.display()); - println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); - println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token); - if vault_id == 1 { - println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)")); + if stealth { + println!("Sanctum: Netzlaufwerk {} bereit.", drive_str); + } else { + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Sanctum Container erfolgreich gemountet │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + println!(" • Container: {}", container_path.display()); + println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); + println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token); + if let Some(secs) = idle_timeout { + println!(" • Auto-Lock: Inaktivität nach {}s", secs); + } + if lock_on_screen_lock { + println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv"); + } + if anti_leak { + println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)"); + } + if enable_tray { + println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)"); + } + println!(); + println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp")); + println!(); } - if let Some(secs) = idle_timeout { - println!(" • Auto-Lock: Inaktivität nach {}s", secs); - } - if lock_on_screen_lock { - println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv"); - } - if anti_leak { - println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)"); - } - if enable_tray { - println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)"); - } - println!(); - println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp")); - println!(); // Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT) let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1); @@ -359,64 +365,88 @@ pub async fn mount_container( tokio::select! { res = tokio::signal::ctrl_c() => { let _ = res; - println!(); - println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); + if !stealth { + println!(); + println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); + } } _ = console_close_rx.recv() => { - println!(); - println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]")); + if !stealth { + println!(); + println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]")); + } } _ = tray_shutdown_rx.recv() => { - println!(); - println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); + if !stealth { + println!(); + println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); + } } _ = session_lock_rx.recv() => { - println!(); - println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); + if !stealth { + println!(); + println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); + } } _ = idle_shutdown_rx.recv() => { - println!(); - println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); - } - } - print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); - let _ = std::io::Write::flush(&mut std::io::stdout()); - - // Automatisches Unmount - if let Err(e) = unmount_drive(drive_letter) { - println!("{}", ui::yellow(&format!("Warnung ({e})"))); - } else { - println!("{}", ui::green("OK")); - } - - // HTTP Server beenden - 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})"))), + if !stealth { + println!(); + println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); } } } - // SQLite WAL Checkpoint erzwingen - print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); - let _ = std::io::Write::flush(&mut std::io::stdout()); - if let Err(e) = db.checkpoint() { - println!("{}", ui::red(&format!("Fehler ({e})"))); + if stealth { + let _ = unmount_drive(drive_letter); + let _ = shutdown_tx.send(true); + let _ = server_handle.await; + if let Ok(freelist) = db.freelist_count() { + if freelist > 0 { + let _ = db.incremental_vacuum(None); + } + } + let _ = db.checkpoint(); + println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str); } else { - println!("{}", ui::green("OK")); - } + print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); + let _ = std::io::Write::flush(&mut std::io::stdout()); - println!(); - println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔")); - println!(); + // Automatisches Unmount + if let Err(e) = unmount_drive(drive_letter) { + println!("{}", ui::yellow(&format!("Warnung ({e})"))); + } else { + println!("{}", ui::green("OK")); + } + + // HTTP Server beenden + 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()); + if let Err(e) = db.checkpoint() { + println!("{}", ui::red(&format!("Fehler ({e})"))); + } else { + println!("{}", ui::green("OK")); + } + + println!(); + println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔")); + println!(); + } Ok(()) } diff --git a/src/storage.rs b/src/storage.rs index 5b10098..b4298d7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -281,6 +281,24 @@ impl Database { Ok(id) } + /// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist. + pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result { + if node_id == ancestor_id { + return Ok(true); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "WITH RECURSIVE sub(id) AS ( + SELECT id FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;", + )?; + let exists: Option = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?; + Ok(exists.is_some()) + } + /// Ü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(); diff --git a/src/vfs.rs b/src/vfs.rs index e2fd490..97fe88a 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -446,6 +446,16 @@ impl SanctumFs { let dek_arc = Arc::new(dek); let carrier_dek_arc = carrier_dek.map(Arc::new); + // Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist, + // um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen. + let carrier_node_id = carrier_node_id.or_else(|| { + if vault_id == 0 { + db.find_carrier_node_id().ok().flatten() + } else { + None + } + }); + let carrier_fs = if vault_id == 1 { if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) { match CarrierFs::load( @@ -787,6 +797,13 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Forbidden); } + // Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält! + if let Some(carrier_id) = self.carrier_node_id { + if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? { + return Err(FsError::Forbidden); + } + } + self.db .delete_node(node.id) .map_err(|_| FsError::GeneralFailure)?; @@ -866,6 +883,10 @@ impl DavFileSystem for SanctumFs { if dest.is_dir { return Err(FsError::Forbidden); } + // Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten! + if self.carrier_node_id == Some(dest.id) { + return Err(FsError::Forbidden); + } self.db .delete_node(dest.id) .map_err(|_| FsError::GeneralFailure)?; @@ -901,6 +922,13 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Forbidden); } + // Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten! + if let Some(dest) = self.resolve_path(&to_str)? { + if self.carrier_node_id == Some(dest.id) { + return Err(FsError::Forbidden); + } + } + let to_parent = self .resolve_path(to_parent_path)? .ok_or(FsError::NotFound)?; @@ -1124,4 +1152,76 @@ mod tests { let new_time = act_arc.load(Ordering::Relaxed); assert!(new_time > 1000); } + + #[tokio::test] + async fn test_carrier_protection_in_decoy_vault() { + let (fs, _dir) = create_test_fs(true); + // Erstelle eine Carrier-Datei "system_backup.dat" + let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap(); + let carrier_id = carrier_node.id; + + // Erstelle FS mit bekanntem carrier_node_id + let protected_fs = SanctumFs::with_carrier( + fs.db.clone(), + (*fs.dek).clone(), + None, + Some(carrier_id), + FORMAT_VERSION, + true, + 0, + ); + + let carrier_path = DavPath::new("/system_backup.dat").unwrap(); + + // 1. Lesen muss erlaubt sein + let mut read_opts = OpenOptions::default(); + read_opts.read = true; + assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok()); + + // 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden) + let mut write_opts = OpenOptions::default(); + write_opts.write = true; + assert!(matches!( + protected_fs.open(&carrier_path, write_opts).await, + Err(FsError::Forbidden) + )); + + let mut trunc_opts = OpenOptions::default(); + trunc_opts.truncate = true; + assert!(matches!( + protected_fs.open(&carrier_path, trunc_opts).await, + Err(FsError::Forbidden) + )); + + // 3. Löschen der Carrier-Datei muss verboten sein + assert!(matches!( + protected_fs.remove_file(&carrier_path).await, + Err(FsError::Forbidden) + )); + + // 4. Umbenennen der Carrier-Datei muss verboten sein + let rename_target = DavPath::new("/renamed_backup.dat").unwrap(); + assert!(matches!( + protected_fs.rename(&carrier_path, &rename_target).await, + Err(FsError::Forbidden) + )); + + // 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein + let other_path = DavPath::new("/other.txt").unwrap(); + let mut other_opts = OpenOptions::default(); + other_opts.write = true; + other_opts.create_new = true; + protected_fs.open(&other_path, other_opts).await.unwrap(); + + assert!(matches!( + protected_fs.rename(&other_path, &carrier_path).await, + Err(FsError::Forbidden) + )); + + // 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein + assert!(matches!( + protected_fs.copy(&other_path, &carrier_path).await, + Err(FsError::Forbidden) + )); + } }