diff --git a/Cargo.lock b/Cargo.lock index 6225c48..293ad36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -160,6 +166,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -596,6 +623,21 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -1193,11 +1235,13 @@ dependencies = [ "aes-gcm", "anyhow", "argon2", + "bip39", "bytes", "clap", "dav-server", "dyn-clone", "futures-util", + "hex", "http-body-util", "hyper", "hyper-util", @@ -1411,6 +1455,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1538,6 +1597,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 6d6b092..b4ac2b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dyn-clone = "1.0" lz4_flex = "0.11" +bip39 = { version = "2.2", features = ["zeroize"] } +hex = "0.4" [profile.release] opt-level = 3 diff --git a/src/crypto.rs b/src/crypto.rs index 3e4ea30..7080525 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -129,6 +129,37 @@ pub fn unwrap_dek( Ok(dek) } +/// 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) + .map_err(|e| anyhow::anyhow!("Fehler beim Erzeugen der BIP-39 Notfallphrase: {e}"))?; + 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(" "); + + 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 entropy = mnemonic.to_entropy(); + if entropy.len() != 32 { + bail!( + "Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}", + entropy.len() + ); + } + + let mut dek = Zeroizing::new([0u8; 32]); + dek.copy_from_slice(&entropy); + Ok(dek) +} + /// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern: /// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian). #[inline] @@ -342,4 +373,44 @@ mod tests { assert_eq!(decrypted, plaintext); } + #[test] + fn test_bip39_recovery_phrase_roundtrip() { + let dek = generate_dek(); + let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic"); + let words: Vec<&str> = mnemonic_str.split_whitespace().collect(); + assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words"); + + let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK"); + assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK"); + + // Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche) + let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n ")); + let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy"); + assert_eq!(*dek, *recovered_messy); + } + + #[test] + fn test_bip39_invalid_words_and_checksum() { + // 1. Nicht im Wörterbuch enthaltenes Wort + let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword"; + assert!(mnemonic_to_dek(invalid_word_phrase).is_err()); + + // 2. Falsche Wortanzahl (z. B. 23 statt 24) + let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow"; + assert!(mnemonic_to_dek(short_phrase).is_err()); + + // 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert) + let dek = generate_dek(); + let mut words: Vec = dek_to_mnemonic(&dek) + .unwrap() + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + // Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort + let original_last = words[23].clone(); + words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() }; + let corrupted_phrase = words.join(" "); + assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); + } } + diff --git a/src/lib.rs b/src/lib.rs index cbd0430..710f405 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod crypto; pub mod mount; +pub mod recovery; pub mod storage; pub mod ui; +pub mod verify; pub mod vfs; diff --git a/src/main.rs b/src/main.rs index 2f75047..1e07b75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,16 @@ use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use sanctum::crypto::{ - derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams, + dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek, + wrap_dek, KdfParams, FORMAT_VERSION, +}; +use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; +use sanctum::recovery::{ + export_header_backup, restore_header_backup, restore_header_from_recovery_key, }; -use sanctum::mount::{format_drive, mount_container, unmount_drive}; use sanctum::storage::Database; use sanctum::ui; +use sanctum::verify::verify_container; #[derive(Parser)] #[command(name = "sanctum")] @@ -43,6 +48,10 @@ enum Commands { /// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443) #[arg(long)] port: Option, + + /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage) + #[arg(long)] + recovery_key: Option, }, /// Trennt ein eingebundenes Netzlaufwerk manuell @@ -57,6 +66,54 @@ enum Commands { /// Pfad zur .sanctum Containerdatei #[arg(short, long)] path: PathBuf, + + /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort) + #[arg(long)] + recovery_key: Option, + }, + + /// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr) + BackupHeader { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + + /// Optionaler Ausgabepfad (Standard: .hdr) + #[arg(short, long)] + output: Option, + }, + + /// Stellt den Container-Header aus einem Backup oder via 24-Wort Notfallschlüssel wieder her + RestoreHeader { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + + /// Pfad zur Header-Backup-Datei (.sanctum.hdr) + #[arg(long)] + header_file: Option, + + /// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort + #[arg(long)] + recovery_key: Option, + }, + + /// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an + RecoveryKey { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + }, + + /// Führt eine Integritätsprüfung (FSCK) und Bitrot-Erkennung auf dem Container durch + Verify { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + + /// Vollständige kryptografische AEAD-Prüfung aller Chunks + #[arg(long, default_value_t = true)] + full: bool, }, } @@ -126,24 +183,29 @@ fn handle_init(container_path: &Path) -> Result<()> { 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 1 (Magic: SANCTUM\\0)"); + println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); println!(" • KDF: Argon2id (M=64MB, T=3, P=4)"); - println!(" • Cipher: AES-256-GCM (1-MB Chunks, AEAD Swap-Schutz)"); + println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)"); 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(()) } -fn handle_passwd(container_path: &Path) -> Result<()> { +fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()> { if !container_path.exists() { bail!( "Containerdatei '{}' existiert nicht.", @@ -157,33 +219,40 @@ fn handle_passwd(container_path: &Path) -> Result<()> { println!(" Container: {}", container_path.display()); println!(); - let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") - .context("Fehler beim Einlesen des aktuellen Passworts")?; - - if old_password.trim().is_empty() { - bail!("Das aktuelle Master-Passwort darf nicht leer sein."); - } - - println!(); - ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; - let meta = db - .read_meta() - .context("Konnte Container-Header nicht lesen")?; + let dek = 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 + } else { + let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") + .context("Fehler beim Einlesen des aktuellen Passworts")?; - 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")?; + if old_password.trim().is_empty() { + bail!("Das aktuelle Master-Passwort darf nicht leer sein."); + } - let dek = unwrap_dek( - &old_kek, - &meta.wrapped_dek, - &meta.header_nonce, - &meta.header_tag, - ) - .map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?; + println!(); + ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); + let meta = db + .read_meta() + .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")?; + + unwrap_dek( + &old_kek, + &meta.wrapped_dek, + &meta.header_nonce, + &meta.header_tag, + ) + .map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))? + }; println!(); let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ") @@ -200,10 +269,6 @@ fn handle_passwd(container_path: &Path) -> Result<()> { bail!("Die eingegebenen Passwörter stimmen nicht überein!"); } - if old_password == new_password { - bail!("Das neue Master-Passwort muss sich vom aktuellen Passwort unterscheiden."); - } - println!(); ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab..."); let new_salt = generate_salt(); @@ -240,6 +305,154 @@ fn handle_passwd(container_path: &Path) -> Result<()> { Ok(()) } +fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Result<()> { + let out = match output_path { + Some(p) => p.to_path_buf(), + None => { + let file_name = container_path + .file_name() + .unwrap_or_default() + .to_string_lossy(); + container_path.with_file_name(format!("{file_name}.hdr")) + } + }; + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Container-Header sichern │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(" Backup-Ziel: {}", out.display()); + println!(); + + export_header_backup(container_path, &out)?; + + println!("{}", ui::green("✔ Header-Backup erfolgreich exportiert!")); + println!(); + println!(" Bewahren Sie diese Sicherungsdatei an einem sicheren Ort auf."); + println!(" Sie enthält alle KDF-Parameter und den verschlüsselten Master-DEK."); + println!(); + + Ok(()) +} + +fn handle_restore_header( + container_path: &Path, + header_file: Option<&Path>, + recovery_key: Option<&str>, +) -> Result<()> { + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Container-Header wiederherstellen │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + + if let Some(hdr) = header_file { + println!(" Verwende Backup-Datei: {}", hdr.display()); + ui::step(1, 2, "📦", "Lese und validiere Header-Backup..."); + restore_header_backup(container_path, hdr)?; + 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 { + println!(" Verwende 24-Wort BIP-39 Notfallschlüssel..."); + let new_password = rpassword::prompt_password("Neues Master-Passwort festlegen: ") + .context("Fehler beim Einlesen des Passworts")?; + + if new_password.trim().is_empty() { + bail!("Das Master-Passwort darf nicht leer sein."); + } + + let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ") + .context("Fehler beim Einlesen der Passwort-Bestätigung")?; + + if new_password != confirm_password { + bail!("Die eingegebenen Passwörter stimmen nicht überein!"); + } + + ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); + restore_header_from_recovery_key(container_path, key, &new_password)?; + ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!"); + 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!(); + + Ok(()) +} + +fn handle_recovery_key(container_path: &Path) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — 24-Wort Notfallschlüssel anzeigen │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + + let password = rpassword::prompt_password("Master-Passwort eingeben: ") + .context("Fehler beim Einlesen des Passworts")?; + + 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 phrase = dek_to_mnemonic(&dek)?; + ui::print_recovery_phrase_card(&phrase); + + Ok(()) +} + +fn handle_verify(container_path: &Path, full: bool) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Container-Integritätsprüfung (FSCK) │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + + let password = rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ") + .context("Fehler beim Einlesen des Passworts")?; + + 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) => { + println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔")); + Some(d) + } + Err(_) => { + println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); + None + } + } + } else { + println!(" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", ui::dim("ℹ")); + None + }; + + let report = verify_container(container_path, dek.as_ref(), full)?; + ui::print_verification_report(&report); + + if !report.is_healthy() { + std::process::exit(1); + } + + Ok(()) +} + async fn run() -> Result<()> { let cli = Cli::parse(); @@ -247,16 +460,26 @@ async fn run() -> Result<()> { Commands::Init { path } => { handle_init(&path)?; } - Commands::Mount { path, drive, port } => { + Commands::Mount { + path, + drive, + port, + recovery_key, + } => { let drive_char = parse_drive_letter(&drive)?; - let prompt_text = format!( - "Master-Passwort für Container '{}' eingeben: ", - path.display() - ); - let password = rpassword::prompt_password(prompt_text) - .context("Fehler beim Einlesen des Passworts")?; + let auth = if let Some(key) = recovery_key { + ContainerAuth::RecoveryKey(key) + } else { + let prompt_text = format!( + "Master-Passwort für Container '{}' eingeben: ", + path.display() + ); + let password = rpassword::prompt_password(prompt_text) + .context("Fehler beim Einlesen des Passworts")?; + ContainerAuth::Password(password) + }; - mount_container(&path, drive_char, port, &password).await?; + mount_container(&path, drive_char, port, auth).await?; } Commands::Unmount { drive } => { let drive_char = parse_drive_letter(&drive)?; @@ -267,8 +490,24 @@ async fn run() -> Result<()> { println!("{}", ui::green("OK")); println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str); } - Commands::Passwd { path } => { - handle_passwd(&path)?; + Commands::Passwd { path, recovery_key } => { + handle_passwd(&path, recovery_key.as_deref())?; + } + Commands::BackupHeader { path, output } => { + handle_backup_header(&path, output.as_deref())?; + } + Commands::RestoreHeader { + path, + header_file, + recovery_key, + } => { + handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref())?; + } + Commands::RecoveryKey { path } => { + handle_recovery_key(&path)?; + } + Commands::Verify { path, full } => { + handle_verify(&path, full)?; } } diff --git a/src/mount.rs b/src/mount.rs index ccb7d0f..695b41d 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -12,11 +12,18 @@ use tokio::net::TcpListener; use tokio::sync::watch; use tracing::{debug, warn}; -use crate::crypto::{derive_kek, unwrap_dek}; +use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek}; use crate::storage::Database; use crate::ui; use crate::vfs::SanctumFs; +/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel. +#[derive(Debug, Clone)] +pub enum ContainerAuth { + Password(String), + RecoveryKey(String), +} + /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") pub fn format_drive(drive_letter: char) -> String { format!("{}:", drive_letter.to_ascii_uppercase()) @@ -73,7 +80,7 @@ pub async fn mount_container( container_path: &Path, drive_letter: char, requested_port: Option, - password: &str, + auth: ContainerAuth, ) -> Result<()> { let drive_str = format_drive(drive_letter); @@ -93,21 +100,33 @@ pub async fn mount_container( .read_meta() .context("Konnte Container-Header nicht lesen")?; - 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 (dek, version) = 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")?; - 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")?; + 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) + } + 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) + } + }; // WebDAV Filesystem und Handler konfigurieren - let fs = SanctumFs::new(db.clone(), dek, meta.version); + let fs = SanctumFs::new(db.clone(), dek, version); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) .locksystem(FakeLs::new()) diff --git a/src/recovery.rs b/src/recovery.rs new file mode 100644 index 0000000..db47c76 --- /dev/null +++ b/src/recovery.rs @@ -0,0 +1,301 @@ +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::crypto::{ + derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, +}; +use crate::storage::{ContainerMeta, Database}; + +pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; +pub const CURRENT_BACKUP_VERSION: u32 = 1; + +/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeaderBackup { + pub magic: String, + pub backup_version: u32, + pub container_format_version: u32, + pub kdf_salt_hex: String, + pub kdf_params: KdfParams, + pub wrapped_dek_hex: String, + pub header_nonce_hex: String, + pub header_tag_hex: String, + pub created_at: u64, +} + +impl HeaderBackup { + pub fn from_meta(meta: &ContainerMeta) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + magic: HEADER_BACKUP_MAGIC.to_string(), + backup_version: CURRENT_BACKUP_VERSION, + container_format_version: meta.version, + kdf_salt_hex: hex::encode(meta.kdf_salt), + kdf_params: meta.kdf_params.clone(), + wrapped_dek_hex: hex::encode(&meta.wrapped_dek), + header_nonce_hex: hex::encode(meta.header_nonce), + header_tag_hex: hex::encode(meta.header_tag), + created_at: now, + } + } + + pub fn to_meta(&self) -> Result { + if self.magic != HEADER_BACKUP_MAGIC { + bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix"); + } + + let salt_bytes = hex::decode(&self.kdf_salt_hex) + .context("Ungültige Hex-Kodierung für KDF-Salt")?; + if salt_bytes.len() != 16 { + bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len()); + } + let mut kdf_salt = [0u8; 16]; + kdf_salt.copy_from_slice(&salt_bytes); + + let wrapped_dek = hex::decode(&self.wrapped_dek_hex) + .context("Ungültige Hex-Kodierung für wrapped_dek")?; + + let nonce_bytes = hex::decode(&self.header_nonce_hex) + .context("Ungültige Hex-Kodierung für Header-Nonce")?; + if nonce_bytes.len() != 12 { + bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len()); + } + let mut header_nonce = [0u8; 12]; + header_nonce.copy_from_slice(&nonce_bytes); + + let tag_bytes = hex::decode(&self.header_tag_hex) + .context("Ungültige Hex-Kodierung für Header-Tag")?; + if tag_bytes.len() != 16 { + bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len()); + } + let mut header_tag = [0u8; 16]; + header_tag.copy_from_slice(&tag_bytes); + + Ok(ContainerMeta { + version: self.container_format_version, + kdf_salt, + kdf_params: self.kdf_params.clone(), + wrapped_dek, + header_nonce, + header_tag, + }) + } +} + +/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr). +pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + let db = Database::open(container_path) + .context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?; + let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + + let backup = HeaderBackup::from_meta(&meta); + let json_data = serde_json::to_string_pretty(&backup) + .context("Fehler beim Serialisieren des Header-Backups")?; + + fs::write(backup_path, json_data) + .with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?; + + Ok(()) +} + +/// Stellt den Container-Header aus einer Sicherungsdatei (.sanctum.hdr) wieder her. +pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> { + if !backup_path.exists() { + bail!("Backup-Datei '{}' existiert nicht.", backup_path.display()); + } + + let content = fs::read_to_string(backup_path) + .with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?; + + let backup: HeaderBackup = serde_json::from_str(&content) + .context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?; + + let meta = backup.to_meta()?; + + let db = Database::open(container_path) + .context("Konnte Ziel-Containerdatei nicht öffnen")?; + + db.restore_meta(&meta) + .context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?; + + db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?; + + Ok(()) +} + +/// Rekonstruiert den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels +/// und initialisiert ein neues Master-Passwort. +pub fn restore_header_from_recovery_key( + container_path: &Path, + recovery_key: &str, + new_password: &str, +) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + if new_password.trim().is_empty() { + bail!("Das neue Master-Passwort darf nicht leer sein."); + } + + // 1. DEK aus 24-Wort-Phrase dekodieren & validieren + let dek = mnemonic_to_dek(recovery_key) + .context("Ungültiger 24-Wort Notfallschlüssel")?; + + // 2. Neuen KEK mit frischem Salt ableiten + let mut salt = [0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); + + let kdf_params = KdfParams::default(); + let kek = derive_kek(new_password, &salt, &kdf_params) + .context("Schlüsselableitung für neues Passwort fehlgeschlagen")?; + + // 3. DEK mit neuem KEK wrappen + let (wrapped_dek, header_nonce, header_tag) = + wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?; + + let meta = ContainerMeta { + version: FORMAT_VERSION, + kdf_salt: salt, + kdf_params, + wrapped_dek, + header_nonce, + header_tag, + }; + + // 4. In Container schreiben + let db = Database::open(container_path) + .context("Konnte Container-Datenbank nicht öffnen")?; + + db.restore_meta(&meta) + .context("Fehler beim Schreiben des rekonstruierten Headers")?; + + db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{generate_dek, generate_salt, unwrap_dek}; + use std::path::PathBuf; + + #[test] + fn test_header_backup_and_restore() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id())); + let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id())); + + if container_path.exists() { + let _ = fs::remove_file(&container_path); + } + if backup_path.exists() { + let _ = fs::remove_file(&backup_path); + } + + let password = "SuperSecretPassword2026!"; + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + let db = Database::open(&container_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.checkpoint().unwrap(); + + // 1. Export + export_header_backup(&container_path, &backup_path).expect("Export header"); + assert!(backup_path.exists()); + + // 2. Header in DB gezielt zerstören/löschen + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + // Prüfen, dass Container jetzt unlesbar ist + let broken_db = Database::open(&container_path).unwrap(); + assert!(broken_db.read_meta().is_err()); + drop(broken_db); + + // 3. Restore + restore_header_backup(&container_path, &backup_path).expect("Restore header"); + + // 4. Verifikation: Container wieder voll entschlüsselbar + let restored_db = Database::open(&container_path).unwrap(); + let meta = restored_db.read_meta().expect("Read restored meta"); + let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap(); + let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) + .expect("Unwrap restored DEK"); + assert_eq!(*dek, *active_dek); + + let _ = fs::remove_file(&container_path); + let _ = fs::remove_file(&backup_path); + } + + #[test] + fn test_restore_from_recovery_key() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id())); + + if container_path.exists() { + let _ = fs::remove_file(&container_path); + } + + let old_password = "ForgottenOldPassword!"; + let new_password = "BrandNewRescuedPassword2026!"; + + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek = derive_kek(old_password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + // 24-Wort Notfallschlüssel sichern + let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap(); + + let db = Database::open(&container_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.checkpoint().unwrap(); + + // Header zerstören + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + // Mit 24-Wort Schlüssel und NEUEM Passwort wiederherstellen + restore_header_from_recovery_key(&container_path, &phrase, new_password) + .expect("Restore from recovery key"); + + // Verifikation: Neues Passwort entschlüsselt originalen DEK + let rescued_db = Database::open(&container_path).unwrap(); + let meta = rescued_db.read_meta().expect("Read rescued meta"); + let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap(); + let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) + .expect("Unwrap rescued DEK"); + assert_eq!(*dek, *unwrapped); + + let _ = fs::remove_file(&container_path); + } +} diff --git a/src/storage.rs b/src/storage.rs index 9fc4f66..08cc6fe 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -507,6 +507,130 @@ impl Database { conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; Ok(()) } + + /// 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 ( + magic BLOB NOT NULL, + version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, + kdf_params TEXT NOT NULL, + wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, + header_tag BLOB NOT NULL + );", + )?; + + 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(), + ], + )?; + + Ok(()) + } + + /// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus. + pub fn run_sqlite_integrity_check(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut issues = Vec::new(); + + // 1. PRAGMA integrity_check + let mut stmt = conn.prepare("PRAGMA integrity_check;")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + for r in rows { + let msg = r?; + if msg.to_lowercase() != "ok" { + issues.push(format!("SQLite integrity error: {msg}")); + } + } + + // 2. PRAGMA foreign_key_check + let mut fk_stmt = conn.prepare("PRAGMA foreign_key_check;")?; + let fk_rows = fk_stmt.query_map([], |row| { + let table: String = row.get(0)?; + let rowid: i64 = row.get(1)?; + let parent: String = row.get(2)?; + let fkid: i64 = row.get(3)?; + Ok(format!( + "Foreign Key Verletzung in Tabelle '{table}', RowId {rowid}, Ziel '{parent}', FK #{fkid}" + )) + })?; + for r in fk_rows { + issues.push(r?); + } + + Ok(issues) + } + + /// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container. + pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> { + let conn = self.conn.lock().unwrap(); + let dirs: i64 = + conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| { + r.get(0) + })?; + let files: i64 = + conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| { + r.get(0) + })?; + let chunks: i64 = conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?; + Ok((dirs as usize, files as usize, chunks as usize)) + } + + /// Listet alle Knoten (Dateien und Ordner) im gesamten Baum auf. + pub fn list_all_nodes(&self) -> 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 ORDER BY id ASC", + )?; + let rows = stmt.query_map([], |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 mut result = Vec::new(); + for r in rows { + result.push(r?); + } + Ok(result) + } + + /// Liefert alle vorhandenen Chunk-Identifikatoren (node_id, chunk_index). + pub fn list_all_chunk_headers(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT node_id, chunk_index FROM chunks ORDER BY node_id, chunk_index")?; + let rows = stmt.query_map([], |row| { + let nid: i64 = row.get(0)?; + let cidx: u32 = row.get(1)?; + Ok((nid, cidx)) + })?; + let mut result = Vec::new(); + for r in rows { + result.push(r?); + } + Ok(result) + } } #[cfg(test)] diff --git a/src/ui.rs b/src/ui.rs index 2b66960..9ba5e4b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -97,3 +97,101 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) { let tag = cyan(&format!("[{}/{}]", num, total)); println!(" {} {} {}", tag, icon, msg); } + +/// Gibt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel in einer hervorgehobenen Sicherheitsbox aus. +pub fn print_recovery_phrase_card(phrase: &str) { + let words: Vec<&str> = phrase.split_whitespace().collect(); + println!(); + println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐")); + println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")); + println!("{}", yellow("├─────────────────────────────────────────────────────────────┤")); + println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │"); + println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │"); + println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │"); + println!("{}", yellow("├─────────────────────────────────────────────────────────────┤")); + + for row in 0..8 { + let w1 = if row < words.len() { + format!("{:2}. {:<11}", row + 1, words[row]) + } else { + "".to_string() + }; + let w2 = if row + 8 < words.len() { + format!("{:2}. {:<11}", row + 9, words[row + 8]) + } else { + "".to_string() + }; + let w3 = if row + 16 < words.len() { + format!("{:2}. {:<11}", row + 17, words[row + 16]) + } else { + "".to_string() + }; + println!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3)); + } + + println!("{}", yellow("└─────────────────────────────────────────────────────────────┘")); + println!(); +} + +/// Gibt den detaillierten Bericht einer Container-Integritätsprüfung aus. +pub fn print_verification_report(report: &crate::verify::VerificationReport) { + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum Container-Integritätsprüfung (FSCK) │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", report.container_path); + println!(" Format: Version {}", report.format_version); + println!(); + + let sqlite_status = if report.sqlite_ok { + green("✔ OK") + } else { + red("✖ FEHLER") + }; + let header_status = if report.header_ok { + green("✔ OK") + } else { + red("✖ BESCHÄDIGT") + }; + let tree_status = if report.orphan_nodes == 0 { + green("✔ KONSISTENT") + } else { + red("✖ INKONSISTENT") + }; + let chunk_status = if report.corrupted_chunks == 0 { + green("✔ AUTHENTIFIZIERT") + } else { + red("✖ BESCHÄDIGT") + }; + + println!(" • SQLite B-Tree Integrität: {}", sqlite_status); + println!(" • Header & KDF-Metadaten: {}", header_status); + println!(" • Verzeichnisbaum & Inodes: {}", tree_status); + println!(" • AEAD Chunk-Authentizität: {}", chunk_status); + println!(); + println!(" Statistiken:"); + println!(" - Ordner: {}", report.total_dirs); + println!(" - Dateien: {}", report.total_files); + println!(" - Daten-Chunks: {}", report.total_chunks); + if report.total_bytes_decrypted > 0 { + let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0); + println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb); + } + + if !report.errors.is_empty() { + println!(); + println!("{}", red(" Gefundene Probleme / Fehler:")); + for err in &report.errors { + println!(" {} {}", red("✖"), err); + } + } + + println!(); + if report.is_healthy() { + println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.")); + } else { + println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")); + } + println!(); +} + diff --git a/src/verify.rs b/src/verify.rs new file mode 100644 index 0000000..cd42015 --- /dev/null +++ b/src/verify.rs @@ -0,0 +1,343 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use zeroize::Zeroizing; + +use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2}; +use crate::storage::Database; + +/// Bericht über das Ergebnis einer Container-Integritätsprüfung. +#[derive(Debug, Clone)] +pub struct VerificationReport { + pub container_path: String, + pub format_version: u32, + pub sqlite_ok: bool, + pub sqlite_errors: Vec, + pub header_ok: bool, + pub header_error: Option, + pub total_nodes: usize, + pub total_dirs: usize, + pub total_files: usize, + pub total_chunks: usize, + pub total_bytes_decrypted: u64, + pub corrupted_chunks: usize, + pub orphan_nodes: usize, + pub errors: Vec, +} + +impl VerificationReport { + pub fn is_healthy(&self) -> bool { + self.sqlite_ok + && self.header_ok + && self.corrupted_chunks == 0 + && self.orphan_nodes == 0 + && self.errors.is_empty() + } +} + +/// Führt eine detaillierte Integritäts- und Bitrot-Prüfung auf einem Sanctum-Container durch. +pub fn verify_container( + container_path: &Path, + dek: Option<&Zeroizing<[u8; 32]>>, + full_chunks: bool, +) -> Result { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + let db = Database::open(container_path) + .context("Konnte Container-Datenbank nicht öffnen")?; + + let mut report = VerificationReport { + container_path: container_path.display().to_string(), + format_version: 0, + sqlite_ok: true, + sqlite_errors: Vec::new(), + header_ok: true, + header_error: None, + total_nodes: 0, + total_dirs: 0, + total_files: 0, + total_chunks: 0, + total_bytes_decrypted: 0, + corrupted_chunks: 0, + orphan_nodes: 0, + errors: Vec::new(), + }; + + // 1. SQLite B-Tree & Foreign Key Prüfung + let sqlite_issues = db.run_sqlite_integrity_check() + .context("Fehler bei der Ausführung des SQLite integrity_check")?; + if !sqlite_issues.is_empty() { + report.sqlite_ok = false; + report.sqlite_errors = sqlite_issues; + } + + // 2. Header & Magic Bytes Prüfung + let meta = match db.read_meta() { + Ok(m) => { + report.format_version = m.version; + if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 { + report.header_ok = false; + report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version)); + } + if m.kdf_salt.len() != 16 { + report.header_ok = false; + report.header_error = Some("Ungültige KDF-Salt-Länge".to_string()); + } + Some(m) + } + Err(e) => { + report.header_ok = false; + report.header_error = Some(format!("{e}")); + None + } + }; + + // 3. Node-Hierarchie & Strukturprüfung + let (dirs, files, chunks_count) = db.count_nodes_and_chunks() + .context("Fehler beim Zählen der Knoten und Chunks")?; + report.total_dirs = dirs; + report.total_files = files; + report.total_chunks = chunks_count; + + let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?; + report.total_nodes = all_nodes.len(); + + let mut node_map = HashMap::new(); + for node in &all_nodes { + node_map.insert(node.id, node.clone()); + } + + // Root-Knoten prüfen (id = 1) + match node_map.get(&1) { + Some(root) => { + if !root.is_dir { + report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string()); + } + if root.parent_id.is_some() { + report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string()); + } + } + None => { + report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string()); + } + } + + // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen + for node in &all_nodes { + if node.id == 1 { + continue; + } + + match node.parent_id { + Some(pid) => match node_map.get(&pid) { + Some(parent) => { + if !parent.is_dir { + report.errors.push(format!( + "Knoten '{}' (id={}) hat einen Parent (id={}), der kein Verzeichnis ist!", + node.name, node.id, pid + )); + } + } + None => { + report.orphan_nodes += 1; + report.errors.push(format!( + "Verwaister Knoten: '{}' (id={}) verweist auf nicht-existenten Parent id={}", + node.name, node.id, pid + )); + } + }, + None => { + report.orphan_nodes += 1; + report.errors.push(format!( + "Verwaister Knoten ohne Parent: '{}' (id={})", + node.name, node.id + )); + } + } + + // Zyklenprüfung + let mut visited = HashSet::new(); + visited.insert(node.id); + let mut curr_parent = node.parent_id; + while let Some(pid) = curr_parent { + if !visited.insert(pid) { + report.errors.push(format!( + "Zyklische Verzeichnisreferenz bei Knoten '{}' (id={}) entdeckt!", + node.name, node.id + )); + break; + } + curr_parent = node_map.get(&pid).and_then(|n| n.parent_id); + } + } + + // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung + let chunk_headers = db.list_all_chunk_headers() + .context("Fehler beim Abrufen der Chunk-Liste")?; + + let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2); + + for (node_id, chunk_index) in chunk_headers { + if !node_map.contains_key(&node_id) { + report.errors.push(format!( + "Verwaister Daten-Chunk: Node #{node_id} Chunk #{chunk_index} gehört zu keinem bekannten Inode!" + )); + } + + if let Some(active_dek) = dek { + if full_chunks { + match db.read_chunk(node_id, chunk_index) { + Ok(Some(record)) => { + match decrypt_chunk( + active_dek, + node_id, + chunk_index, + &record.ciphertext, + &record.nonce, + &record.tag, + format_version, + ) { + Ok(plaintext) => { + report.total_bytes_decrypted += plaintext.len() as u64; + } + Err(e) => { + report.corrupted_chunks += 1; + report.errors.push(format!( + "AEAD/Integritätsfehler bei Node #{node_id} Chunk #{chunk_index}: {e}" + )); + } + } + } + Ok(None) => { + report.errors.push(format!( + "Chunk #{chunk_index} für Node #{node_id} in Index gefunden, aber Daten nicht lesbar!" + )); + } + Err(e) => { + report.corrupted_chunks += 1; + report.errors.push(format!( + "DB-Lesefehler bei Node #{node_id} Chunk #{chunk_index}: {e}" + )); + } + } + } + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{ + derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION, + }; + use std::fs; + use std::path::PathBuf; + + #[test] + fn test_verify_healthy_container() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = + temp_dir.join(format!("test_verify_ok_{}.sanctum", std::process::id())); + + if container_path.exists() { + let _ = fs::remove_file(&container_path); + } + + let password = "HealthyTestPassword123!"; + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + let db = Database::open(&container_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + + // Verzeichnis & Datei anlegen + let folder = db.create_node(1, "photos", true).unwrap(); + let file = db.create_node(folder.id, "img.jpg", false).unwrap(); + + // 2 Chunks schreiben + let chunk0_data = b"Sample JPEG data header and pixels"; + let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap(); + db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap(); + + let chunk1_data = b"Additional payload data bytes"; + let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap(); + db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap(); + + db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap(); + db.checkpoint().unwrap(); + + // Verifizieren + let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); + assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); + assert_eq!(report.total_files, 1); + assert_eq!(report.total_dirs, 2); // Root + photos + assert_eq!(report.total_chunks, 2); + assert_eq!(report.corrupted_chunks, 0); + assert_eq!(report.orphan_nodes, 0); + + let _ = fs::remove_file(&container_path); + } + + #[test] + fn test_verify_detects_bitrot() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = + temp_dir.join(format!("test_verify_bitrot_{}.sanctum", std::process::id())); + + if container_path.exists() { + let _ = fs::remove_file(&container_path); + } + + let password = "BitrotTestPassword123!"; + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + let db = Database::open(&container_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + + let file = db.create_node(1, "document.pdf", false).unwrap(); + let chunk_data = b"Vital documents that must not be corrupted"; + let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap(); + db.write_chunk(file.id, 0, &n, &t, &ct).unwrap(); + db.checkpoint().unwrap(); + drop(db); + + // Bitrot simulieren: Wir flippen 1 Byte im Ciphertext in SQLite direkt + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let mut corrupted_ct = ct.clone(); + corrupted_ct[4] ^= 0xFF; // Bit-Flip! + conn.execute( + "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0", + rusqlite::params![corrupted_ct, file.id], + ).unwrap(); + drop(conn); + + // Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven! + let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); + assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot"); + assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk"); + assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler"))); + + let _ = fs::remove_file(&container_path); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index b8bece8..54035eb 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -9,10 +9,12 @@ use dav_server::{ use futures_util::StreamExt; use sanctum::{ crypto::{ - derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams, - CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, + dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, + wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, }, + recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key}, storage::Database, + verify::verify_container, vfs::SanctumFs, }; @@ -372,3 +374,128 @@ async fn test_sanctum_v1_backward_compatibility() { let _ = std::fs::remove_file(&container_path); } +#[tokio::test] +async fn test_sanctum_disaster_recovery_workflow() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = + temp_dir.join(format!("test_disaster_{}.sanctum", std::process::id())); + let backup_path: PathBuf = + temp_dir.join(format!("test_disaster_{}.sanctum.hdr", std::process::id())); + + if container_path.exists() { + let _ = std::fs::remove_file(&container_path); + } + if backup_path.exists() { + let _ = std::fs::remove_file(&backup_path); + } + + let initial_password = "PrimaryPassword2026!"; + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK"); + let dek = generate_dek(); + let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap"); + + // 1. Container erstellen und Datei schreiben + let db = Database::open(&container_path).expect("open db"); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema"); + + let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); + let test_file = DavPath::new("/confidential.doc").unwrap(); + let secret_payload = b"Top secret corporate documents protected by Sanctum."; + let mut opts_w = OpenOptions::default(); + opts_w.write = true; + opts_w.create_new = true; + let mut f = fs.open(&test_file, opts_w).await.expect("create file"); + f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write"); + f.flush().await.expect("flush"); + drop(f); + drop(fs); + db.checkpoint().expect("checkpoint"); + + // 2. Header-Backup exportieren & 24-Wort Notfallschlüssel sichern + export_header_backup(&container_path, &backup_path).expect("Export header backup"); + assert!(backup_path.exists()); + let recovery_phrase = dek_to_mnemonic(&dek).expect("Generate 24-word recovery phrase"); + assert_eq!(recovery_phrase.split_whitespace().count(), 24); + + // 3. Integritätsprüfung vor Katastrophe + let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial"); + assert!(initial_report.is_healthy()); + assert_eq!(initial_report.total_files, 1); + assert_eq!(initial_report.corrupted_chunks, 0); + + // 4. KATASTROPHE 1: Header in SQLite löschen + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + // Container darf sich ohne Header nicht mehr öffnen lassen + let broken_db = Database::open(&container_path).unwrap(); + assert!(broken_db.read_meta().is_err()); + drop(broken_db); + + // 5. WIEDERHERSTELLUNG 1: Aus externem .sanctum.hdr Backup restoren + restore_header_backup(&container_path, &backup_path).expect("Restore from backup file"); + + let restored_db = Database::open(&container_path).unwrap(); + let restored_meta = restored_db.read_meta().expect("Read restored meta"); + let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap(); + let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap(); + + let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version); + let mut opts_r = OpenOptions::default(); + opts_r.read = true; + let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored"); + let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read"); + assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore"); + drop(rf1); + drop(fs1); + drop(restored_db); + + // 6. KATASTROPHE 2: Header erneut zerstört UND ursprüngliches Passwort vergessen! + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + // 7. WIEDERHERSTELLUNG 2: Via 24-Wort Notfallschlüssel mit BRANDNEUEM Passwort + let brand_new_password = "BrandNewRescuedVaultPassphrase2026!"; + restore_header_from_recovery_key(&container_path, &recovery_phrase, brand_new_password) + .expect("Restore from 24-word recovery key"); + + let rescued_db = Database::open(&container_path).unwrap(); + let rescued_meta = rescued_db.read_meta().expect("Read rescued meta"); + let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap(); + let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap(); + + let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version); + let mut opts_r2 = OpenOptions::default(); + opts_r2.read = true; + let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password"); + let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read"); + assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue"); + drop(rf2); + drop(fs2); + drop(rescued_db); + + // 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let mut current_ct: Vec = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap(); + current_ct[0] ^= 0x01; // Bit-Flip + conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap(); + drop(conn); + + let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot"); + assert!(!bitrot_report.is_healthy(), "Container must flag bitrot"); + assert_eq!(bitrot_report.corrupted_chunks, 1); + + // Aufräumen + let _ = std::fs::remove_file(&container_path); + let _ = std::fs::remove_file(&backup_path); +} + +