diff --git a/CHANGELOG.md b/CHANGELOG.md index 072e165..f8be4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semve ## [Unreleased] +### Added +- **`sanctum passwd` Befehl**: Sicheres und sofortiges Ändern des Master-Passworts über DEK-Rewrapping mit frischem Argon2id-Salt und neuen KEK-Parametern, ohne die Nutzdaten-Chunks neu verschlüsseln zu müssen. +- Integrationstest `test_sanctum_password_change` zur automatisierten Verifikation von Authentifizierungswechsel und Datenintegrität. + ## [0.1.0] - 2026-09-07 ### Added diff --git a/README.md b/README.md index f4db56a..e9777b6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ Zum Beenden und sicheren Trennen einfach `Ctrl+C` im Terminal drücken. sanctum.exe unmount --drive S ``` +### 4. Master-Passwort ändern +```powershell +sanctum.exe passwd --path "C:\Users\username\Documents\safe.sanctum" +``` +Ändert das Master-Passwort über Key-Wrapping in Sekundenbruchteilen, ohne dass Nutzdatenblöcke neu verschlüsselt werden müssen. + + --- ## 📦 Release Packaging diff --git a/src/main.rs b/src/main.rs index ba35875..2f75047 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,9 @@ use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; -use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams}; +use sanctum::crypto::{ + derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams, +}; use sanctum::mount::{format_drive, mount_container, unmount_drive}; use sanctum::storage::Database; use sanctum::ui; @@ -49,6 +51,13 @@ enum Commands { #[arg(short, long)] drive: String, }, + + /// Ändert das Master-Passwort eines bestehenden Sanctum-Containers + Passwd { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + }, } fn parse_drive_letter(s: &str) -> Result { @@ -134,6 +143,103 @@ fn handle_init(container_path: &Path) -> Result<()> { Ok(()) } +fn handle_passwd(container_path: &Path) -> Result<()> { + if !container_path.exists() { + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); + } + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Master-Passwort ändern │"); + println!("└─────────────────────────────────────────────────────────────┘"); + 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")?; + + 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 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!(); + let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ") + .context("Fehler beim Einlesen des neuen Passworts")?; + + if new_password.trim().is_empty() { + bail!("Das neue 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!"); + } + + 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(); + let new_params = KdfParams::default(); + let new_kek = derive_kek(&new_password, &new_salt, &new_params) + .context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?; + + let (new_wrapped_dek, new_nonce, new_tag) = + 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( + &new_salt, + &new_params, + &new_wrapped_dek, + &new_nonce, + &new_tag, + ) + .context("Fehler beim Aktualisieren der Container-Metadaten")?; + + db.checkpoint() + .context("Fehler beim WAL-Checkpoint nach Passwortänderung")?; + + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Master-Passwort erfolgreich geändert! │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + println!(" • Container: {}", container_path.display()); + println!(" • KDF: Argon2id mit frischem Salt"); + println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)"); + println!(); + + Ok(()) +} + async fn run() -> Result<()> { let cli = Cli::parse(); @@ -161,11 +267,15 @@ async fn run() -> Result<()> { println!("{}", ui::green("OK")); println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str); } + Commands::Passwd { path } => { + handle_passwd(&path)?; + } } Ok(()) } + #[tokio::main] async fn main() { // Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren diff --git a/src/storage.rs b/src/storage.rs index 7c084f8..0f0a71d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -225,6 +225,35 @@ impl Database { }) } + /// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK im Header (Passwortänderung). + pub fn update_meta_keys( + &self, + 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", + params![ + new_salt.as_slice(), + params_json, + new_wrapped_dek, + new_header_nonce.as_slice(), + new_header_tag.as_slice(), + ], + )?; + + if rows_affected == 0 { + bail!("Konnte Container-Header nicht aktualisieren: meta-Tabelle ist leer"); + } + + 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> { let normalized = raw_path.trim_matches('/'); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ae620a3..b9f246f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -158,3 +158,94 @@ async fn test_sanctum_full_container_lifecycle() { assert!(container_path.exists()); let _ = std::fs::remove_file(&container_path); } + +#[tokio::test] +async fn test_sanctum_password_change() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = temp_dir.join(format!("test_sanctum_passwd_{}.sanctum", std::process::id())); + + if container_path.exists() { + let _ = std::fs::remove_file(&container_path); + } + + let password_v1 = "InitialSecret123!"; + let password_v2 = "NewSecret456!"; + + // 1. Initialisierung mit Passwort v1 + let salt_v1 = generate_salt(); + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1"); + let original_dek = generate_dek(); + let (wrapped_dek_v1, nonce_v1, tag_v1) = + wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1"); + + let db = Database::open(&container_path).expect("Open database"); + db.init_schema(&salt_v1, &kdf_params, &wrapped_dek_v1, &nonce_v1, &tag_v1) + .expect("Init schema"); + db.checkpoint().expect("Checkpoint"); + + // Datei im Container mit DEK anlegen + let fs_v1 = SanctumFs::new(db.clone(), original_dek.clone()); + let file_path = DavPath::new("/secret.txt").unwrap(); + let file_data = b"Verschluesselte Geheimdaten vor der Passwortaenderung!"; + let mut opts_write = OpenOptions::default(); + opts_write.write = true; + opts_write.create_new = true; + let mut write_file = fs_v1 + .open(&file_path, opts_write) + .await + .expect("Open write"); + write_file + .write_bytes(Bytes::from_static(file_data)) + .await + .expect("Write bytes"); + write_file.flush().await.expect("Flush"); + drop(write_file); + drop(fs_v1); + db.checkpoint().expect("Checkpoint"); + + // 2. Passwortwechsel durchführen + let meta_before = db.read_meta().expect("Read meta"); + let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params).expect("Derive old KEK"); + let recovered_dek = unwrap_dek(&old_kek, &meta_before.wrapped_dek, &meta_before.header_nonce, &meta_before.header_tag) + .expect("Unwrap with old password"); + + let salt_v2 = generate_salt(); + let kek_v2 = derive_kek(password_v2, &salt_v2, &kdf_params).expect("Derive new KEK"); + let (wrapped_dek_v2, nonce_v2, tag_v2) = wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK"); + + db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2).expect("Update meta keys"); + db.checkpoint().expect("Checkpoint"); + + // 3. Verifikation: Altes Passwort darf NICHT mehr funktionieren + let meta_after = db.read_meta().expect("Read meta after"); + let old_kek_again = derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap(); + assert!( + unwrap_dek(&old_kek_again, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag).is_err(), + "Altes Passwort darf nach Passwortaenderung nicht mehr funktionieren!" + ); + + // 4. Verifikation: Neues Passwort funktioniert und entschlüsselt alte Daten intakt + let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params).expect("Derive new KEK"); + let active_dek = unwrap_dek(&new_kek, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag) + .expect("Unwrap with new password"); + + let fs_v2 = SanctumFs::new(db.clone(), active_dek); + let mut opts_read = OpenOptions::default(); + opts_read.read = true; + let mut read_file = fs_v2.open(&file_path, opts_read).await.expect("Open read with new password"); + let read_bytes = read_file.read_bytes(file_data.len()).await.expect("Read bytes"); + assert_eq!(&read_bytes[..], file_data, "Daten muessen nach Passwortaenderung unveraendert lesbar sein!"); + + + drop(read_file); + drop(fs_v2); + drop(db); + + let _ = std::fs::remove_file(&container_path); +} +