diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c9a68..bc3288a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). +## [0.8.0] - 2026-09-19 + +### Security Audit Remediation & Format V3 Release +Dieses Release implementiert die umfassende Härtung aus zwei Security-Audit-Runden, rüstet das Container-Format auf V3 auf und führt wesentliche Sicherheits- und Notfallfunktionen ein. + +#### Security & Architecture (Findings S-01, V-01, V-02, K-01, K-02, K-03) +- **K-01: Kanonische Metadaten-Authentifizierung (Format V3)**: + - Ergänzung einer HMAC-SHA-256 Metadaten-MAC (`metadata_mac`) im Header, abgeleitet aus dem DEK. + - Verhindert unbemerkte Manipulation von Verzeichnisstrukturen, Dateigrößen oder Dateitypen. +- **K-02: Chunk-Replay- & Reordering-Schutz (Format V3)**: + - Erweiterung des AAD von 16 Bytes auf 24 Bytes: Bindung an `node_id` (8 Bytes), `chunk_index` (8 Bytes) und monotone `generation` (8 Bytes). + - Verhindert das Wiedereinspielen veralteter Chunks durch Angreifer mit Schreibzugriff auf die Containerdatei. +- **K-03: Notfallblatt-Lebenszyklus & `rekey`-Kommando**: + - `sanctum passwd` weist nun explizit darauf hin, dass das 24-Wörter Notfallblatt (BIP-39) weiterhin gültig bleibt. + - Neues CLI-Kommando `sanctum rekey`: Generiert einen frischen DEK, verschlüsselt alle Chunks um, aktualisiert Header sowie Metadaten-MAC und entwertet das bisherige Notfallblatt unwiderruflich. +- **S-01: Sichere Löschlogik in `sanctum sync`**: + - `--delete` löscht keine ausgeschlossenen Pfade (`--exclude`) mehr. Für das Löschen ausgeschlossener Dateien muss explizit `--delete-excluded` übergeben werden (analog zu rsync). +- **V-01: CarrierFs-Dispatch im Carrier-Modus**: + - `SanctumFs::copy` leitet im Trägermodus Kopieroperationen korrekt an das Trägerdateisystem weiter. +- **V-02: Schutz vor stiller Dateikürzung bei fehlenden Chunks**: + - Fehlt ein Datenblock innerhalb der erwarteten Dateigröße, wird ein harter E/A-Fehler (`EIO` / `DatabaseInconsistency`) ausgelöst statt stiller Kürzung auf 0 Bytes. + ## [0.7.2] - 2026-09-18 ### Security Audit Remediation Release (Findings SA-01 to SA-07) diff --git a/LEGAL.md b/LEGAL.md index 2ef86d0..5cabde0 100644 --- a/LEGAL.md +++ b/LEGAL.md @@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT ### B. Eigenverantwortung für Backups & Notfallschlüssel * Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen. * Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich. +* **Gültigkeit von Notfallkarten bei Passwortänderung**: Ein normales Ändern des Master-Passworts (`sanctum passwd`) ändert lediglich den KEK und belässt den Datenschlüssel (DEK) unverändert; bestehende Notfallkarten bleiben somit vollumfänglich funktionsfähig. Zur Entwertung kompromittierter Notfallkarten und vollständigen Neugenerierung des Datenschlüssels muss explizit der Befehl `sanctum rekey` ausgeführt werden. --- diff --git a/QUICKSTART.md b/QUICKSTART.md index 270c993..d5464ab 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -73,7 +73,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode ```powershell sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key ``` - *(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).* + *(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort. Hinweis: Das Notfallblatt bleibt danach weiterhin gültig!).* + +* **Notfallkarte verloren oder kompromittiert? Schlüssel erneuern (Rekeying)**: + ```powershell + sanctum.exe rekey --path "D:\Tresor\daten.sanctum" + ``` + *(Generiert einen frischen Verschlüsselungsschlüssel, verschlüsselt alle Chunks um und entwertet das bisherige Notfallblatt unwiderruflich. Gibt eine neue 24-Wort Notfallkarte aus).* --- diff --git a/README.md b/README.md index acea746..314b816 100644 --- a/README.md +++ b/README.md @@ -121,12 +121,21 @@ sanctum.exe unmount --drive S --- -### 4. Master-Passwort ändern +### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`) ```powershell +# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen): sanctum.exe passwd --path "C:\Pfad\tresor.sanctum" ``` -Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen. +> [!NOTE] +> `passwd` verpackt den bestehenden Datenschlüssel (DEK) mit einem neuen KEK. Das zuvor notierte **24-Wort Notfallblatt bleibt weiterhin gültig**! + +```powershell +# Master-Schlüssel erneuern & bisherige Notfallkarten entwerten (K-03): +sanctum.exe rekey --path "C:\Pfad\tresor.sanctum" +``` +> [!IMPORTANT] +> `rekey` generiert einen komplett frischen DEK, verschlüsselt alle Datenblöcke um und gibt ein **neues 24-Wort Notfallblatt** aus. Alle bisherigen Notfallkarten werden dadurch **unwiderruflich ungültig**. --- @@ -183,9 +192,12 @@ sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Down # 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen): sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp" -# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel): +# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel, schont aber per --exclude ausgeschlossene Pfade): sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete +# 3b. Radikales Löschen inklusive ausgeschlossener Pfade: +sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete --delete-excluded + # 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen): sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum diff --git a/src/main.rs b/src/main.rs index dae3275..9fb8dfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,6 +134,13 @@ enum Commands { recovery_key: Option, }, + /// Erneuert den internen Verschlüsselungsschlüssel (DEK), verschlüsselt alle Chunks um und generiert ein neues Notfallblatt (K-03) + Rekey { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + }, + /// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers Backup { /// Pfad zur Quelldatei (.sanctum Containerdatei) @@ -879,6 +886,86 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() println!(" • KDF: Argon2id mit frischem Salt"); println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)"); println!(); + println!(" [!] HINWEIS: Das 24-Wörter-Notfallblatt bleibt weiterhin gültig!"); + println!(" Um den Container vollständig mit neuem Master-Schlüssel zu sichern,"); + println!(" nutzen Sie `sanctum rekey`."); + println!(); + + Ok(()) +} + +fn handle_rekey(container_path: &Path) -> Result<()> { + if !container_path.exists() { + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); + } + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Master-Schlüssel erneuern (Rekeying) │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + println!(" [!] WICHTIGER SICHERHEITSHINWEIS (K-03):"); + println!(" Beim Rekeying wird der interne Daten-Verschlüsselungsschlüssel (DEK)"); + println!(" neu generiert und alle Datenblöcke werden umverschlüsselt."); + println!(" Das bisherige 24-Wörter-Notfallblatt wird damit UNWIDERRUFLICH UNGÜLTIG."); + println!(); + + let password = Zeroizing::new( + rpassword::prompt_password("Master-Passwort des Containers eingeben: ") + .context("Fehler beim Einlesen des Master-Passworts")?, + ); + + if password.trim().is_empty() { + bail!("Das Master-Passwort darf nicht leer sein."); + } + + println!(); + ui::step( + 1, + 3, + "🔄", + "Entschlüssele Datenblöcke & verschlüssele mit neuem DEK...", + ); + let new_phrase = sanctum::recovery::rekey_container(container_path, &password) + .context("Fehler beim Ausführen des Rekeyings")?; + ui::step( + 2, + 3, + "🔒", + "Aktualisiere Container-Header & Metadaten-Authentifizierung...", + ); + ui::step(3, 3, "🔑", "Generiere neues 24-Wort BIP-39 Notfallblatt..."); + + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Container erfolgreich rekeyed! │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + println!(" • Container: {}", container_path.display()); + println!(" • Status: Alle Chunks wurden unter einem frischen DEK neu verschlüsselt."); + println!(" • Alt-Schlüssel:Bisheriges Notfallblatt ist ab sofort ungültig und entwertet."); + println!(); + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ NEUES 24-WORT BIP-39 NOTFALLBLATT (NOTFALL-WIEDERHERSTELLUNG)│"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + let words: Vec<&str> = new_phrase.split_whitespace().collect(); + for (i, chunk) in words.chunks(4).enumerate() { + let line = chunk + .iter() + .enumerate() + .map(|(j, w)| format!("{:2}. {:<12}", i * 4 + j + 1, w)) + .collect::>() + .join(" "); + println!(" {}", line); + } + println!(); + println!(" [!] WICHTIG: Drucken Sie diese Wörter aus oder notieren Sie sie sicher."); + println!(" Das alte Notfallblatt kann nicht mehr zur Wiederherstellung verwendet werden."); + println!(); Ok(()) } @@ -1573,6 +1660,9 @@ async fn run() -> Result<()> { Commands::UpgradeFormat { path } => { handle_upgrade_format(&path)?; } + Commands::Rekey { path } => { + handle_rekey(&path)?; + } Commands::Sync { path, source, diff --git a/src/recovery.rs b/src/recovery.rs index 091b9e6..0cb3eed 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -427,6 +427,73 @@ pub fn restore_header_from_recovery_key( restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None) } +/// Führt ein vollständiges Rekeying eines Containers durch (K-03). +/// Generiert einen neuen kryptografischen DEK, verschlüsselt alle Chunks um, +/// verpackt den neuen DEK mit dem bestehenden Passwort und gibt das neue +/// 24-Wort Notfallblatt (BIP-39 Mnemonic) zurück. +/// Das alte Notfallblatt wird dadurch unwiderruflich ungültig. +pub fn rekey_container(container_path: &Path, password: &str) -> 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 meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; + let keys = meta.authenticate(password).ok_or_else(|| { + anyhow::anyhow!("Ungültiges Master-Passwort! Authentifizierung fehlgeschlagen.") + })?; + + let old_dek = keys.dek().clone(); + let slot_id = keys.slot_id(); + let version = keys.version(); + let carrier_dek = keys.carrier_dek(); + let carrier_node_id = keys.carrier_node_id(); + + // 1. Neuen DEK generieren + let new_dek = crate::crypto::generate_dek(); + + // 2. Chunks des aktiven Vaults umverschlüsseln + db.rekey_vault(slot_id, &old_dek, &new_dek, version)?; + + // 3. Neuen KEK ableiten und DEK neu verpacken + let new_salt = crate::crypto::generate_salt(); + let new_params = KdfParams::default(); + let new_kek = derive_kek(password, &new_salt, &new_params)?; + + let (new_wrapped_dek, new_nonce, new_tag) = if slot_id == 1 { + let c_dek = carrier_dek + .ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?; + let c_nid = carrier_node_id.unwrap_or(0); + wrap_slot1_payload(&new_kek, &new_dek, &c_dek, c_nid)? + } else { + let c_nid = carrier_node_id.unwrap_or(0); + wrap_slot0_payload(&new_kek, &new_dek, c_nid)? + }; + + db.update_slot_keys( + slot_id, + &new_salt, + &new_params, + &new_wrapped_dek, + &new_nonce, + &new_tag, + )?; + + // 4. Metadaten-MAC mit neuem DEK aktualisieren + db.set_active_slot_and_dek(slot_id, new_dek.clone()); + db.update_metadata_mac()?; + db.checkpoint()?; + + // 5. Neues BIP-39 Notfallblatt generieren + let new_phrase = crate::crypto::dek_to_mnemonic(&new_dek)?; + Ok(new_phrase) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/storage.rs b/src/storage.rs index 2bf8242..371f0f7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1128,6 +1128,126 @@ impl Database { } } + /// Verschlüsselt alle Chunks eines Vaults mit einem neuen DEK um (K-03 Rekeying). + pub fn rekey_vault( + &self, + slot_id: u32, + old_dek: &[u8; 32], + new_dek: &[u8; 32], + version: u32, + ) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + // Ermittle alle Node-IDs dieses Vaults + let node_ids: Vec = if slot_id == 1 { + let mut stmt = tx.prepare( + "WITH RECURSIVE vault1(id) AS ( + SELECT 2 + UNION ALL + SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id + ) SELECT id FROM vault1", + )?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.filter_map(|r| r.ok()).collect() + } else { + let mut stmt = tx.prepare( + "WITH RECURSIVE vault0(id) AS ( + SELECT 1 + UNION ALL + SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id + ) SELECT id FROM vault0", + )?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.filter_map(|r| r.ok()).collect() + }; + + // Option B: Falls Slot 1, verschlüsselte Dateinamen unter parent_id = 2 umverschlüsseln + if slot_id == 1 { + let mut name_stmt = + tx.prepare("SELECT id, parent_id, name FROM nodes WHERE parent_id = 2")?; + let node_rows: Vec<(i64, i64, String)> = name_stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .filter_map(|r| r.ok()) + .collect(); + drop(name_stmt); + + for (id, parent_id, enc_name) in node_rows { + if let Some(decrypted_name) = + crate::crypto::decrypt_node_name(old_dek, parent_id, &enc_name) + { + let new_enc = + crate::crypto::encrypt_node_name(new_dek, parent_id, &decrypted_name); + tx.execute( + "UPDATE nodes SET name = ?1 WHERE id = ?2", + params![new_enc, id], + )?; + } + } + } + + // Chunks der betroffenen Nodes umverschlüsseln + for nid in node_ids { + let mut chunk_stmt = tx.prepare( + "SELECT chunk_index, generation, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1", + )?; + let chunk_rows: Vec<(u32, u64, [u8; 12], [u8; 16], Vec)> = chunk_stmt + .query_map(params![nid], |row| { + let chunk_index: u32 = row.get(0)?; + let generation: i64 = row.get(1).unwrap_or(0); + let nonce_vec: Vec = row.get(2)?; + let tag_vec: Vec = row.get(3)?; + let ciphertext: Vec = row.get(4)?; + let mut nonce = [0u8; 12]; + let mut tag = [0u8; 16]; + if nonce_vec.len() == 12 { + nonce.copy_from_slice(&nonce_vec); + } + if tag_vec.len() == 16 { + tag.copy_from_slice(&tag_vec); + } + Ok((chunk_index, generation as u64, nonce, tag, ciphertext)) + })? + .filter_map(|r| r.ok()) + .collect(); + drop(chunk_stmt); + + for (chunk_index, generation, nonce, tag, ct) in chunk_rows { + let plaintext = crate::crypto::decrypt_chunk( + old_dek, + nid, + chunk_index, + &ct, + &nonce, + &tag, + version, + generation, + )?; + let (new_ct, new_nonce, new_tag) = crate::crypto::encrypt_chunk( + new_dek, + nid, + chunk_index, + &plaintext, + version, + generation, + )?; + tx.execute( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", + params![ + new_nonce.as_slice(), + new_tag.as_slice(), + new_ct, + nid, + chunk_index, + ], + )?; + } + } + + tx.commit()?; + Ok(()) + } + /// Löst einen hierarchischen Pfad innerhalb eines bestimmten Vaults auf. pub fn resolve_path_in_vault( &self, @@ -1824,7 +1944,12 @@ impl Database { let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let mac_key = derive_metadata_mac_key(dek); - Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac)) + Ok(verify_metadata_mac( + &mac_key, + gen, + &canonical, + &expected_mac, + )) } /// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02). @@ -1852,9 +1977,8 @@ impl Database { // K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln { - let mut chunk_stmt = conn.prepare( - "SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks", - )?; + let mut chunk_stmt = + conn.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?; let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec)> = chunk_stmt .query_map([], |row| { let node_id: i64 = row.get(0)?; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 56680a6..236a903 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -13,7 +13,10 @@ use sanctum::{ wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }, - recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key}, + recovery::{ + export_header_backup, rekey_container, restore_header_backup, + restore_header_from_recovery_key, + }, storage::Database, verify::verify_container, vfs::SanctumFs, @@ -1561,3 +1564,229 @@ async fn test_webdav_loopback_session_token_and_host_validation() { let _ = server_handle.await; let _ = std::fs::remove_file(&container_path); } + +#[tokio::test] +async fn test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet() { + let temp_dir = std::env::temp_dir(); + let container_path: PathBuf = temp_dir.join(format!( + "test_sanctum_k03_{}_{}.sanctum", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + if container_path.exists() { + let _ = std::fs::remove_file(&container_path); + } + + let password_v1 = "InitialPassword2026!"; + let password_v2 = "ChangedPassword2026!"; + + // 1. Initialisiere Container + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let kek = derive_kek(password_v1, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); + + let db = Database::open(&container_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .unwrap(); + db.checkpoint().unwrap(); + + // 2. Schreibe eine Testdatei + let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); + let secret_path = DavPath::new("/secret_doc.txt").unwrap(); + let mut opts = OpenOptions::default(); + opts.write = true; + opts.create_new = true; + let mut f = fs.open(&secret_path, opts).await.unwrap(); + let secret_data = b"Confidential financial data protected by Sanctum K-03."; + f.write_buf(Box::new(Bytes::from_static(secret_data))) + .await + .unwrap(); + drop(f); + drop(fs); + db.checkpoint().unwrap(); + + // Initiales Notfallblatt + let initial_mnemonic = dek_to_mnemonic(&dek).unwrap(); + + // 3. Simuliere normales Passwort-Ändern (sanctum passwd) + // Bei passwd wird der DEK beibehalten, nur mit neuem KEK verpackt + let new_salt = generate_salt(); + let new_kek = derive_kek(password_v2, &new_salt, &kdf_params).unwrap(); + let (new_wrapped_dek, new_nonce, new_tag) = + sanctum::crypto::wrap_slot0_payload(&new_kek, &dek, 0).unwrap(); + db.update_slot_keys( + 0, + &new_salt, + &kdf_params, + &new_wrapped_dek, + &new_nonce, + &new_tag, + ) + .unwrap(); + db.checkpoint().unwrap(); + + // Verifiziere: Das ursprüngliche Notfallblatt ist WEITERHIN gültig nach normalem passwd! + // (Beweis für Finding K-03, weshalb Warnung + Rekeying notwendig sind) + { + // Lösche Header und stelle mit initial_mnemonic + temp password wieder her + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + restore_header_from_recovery_key(&container_path, &initial_mnemonic, "TempPass2026!") + .expect("Altes Notfallblatt muss nach passwd weiterhin funktionieren"); + + let r_db = Database::open(&container_path).unwrap(); + let r_meta = r_db.read_meta().unwrap(); + let r_kek = derive_kek("TempPass2026!", &r_meta.kdf_salt, &r_meta.kdf_params).unwrap(); + let r_dek = unwrap_dek( + &r_kek, + &r_meta.wrapped_dek, + &r_meta.header_nonce, + &r_meta.header_tag, + ) + .unwrap(); + assert_eq!( + *dek, *r_dek, + "DEK muss nach normalem passwd identisch geblieben sein" + ); + + let r_fs = SanctumFs::new(r_db.clone(), r_dek, r_meta.version); + let mut r_opts = OpenOptions::default(); + r_opts.read = true; + let mut r_f = r_fs.open(&secret_path, r_opts).await.unwrap(); + let read_bytes = r_f.read_bytes(secret_data.len()).await.unwrap(); + assert_eq!(&read_bytes[..], secret_data); + } + + // Setze Header wieder auf password_v2 + let fresh_salt = generate_salt(); + let fresh_kek = derive_kek(password_v2, &fresh_salt, &kdf_params).unwrap(); + let (fresh_wrapped, fresh_nonce, fresh_tag) = + sanctum::crypto::wrap_slot0_payload(&fresh_kek, &dek, 0).unwrap(); + db.update_slot_keys( + 0, + &fresh_salt, + &kdf_params, + &fresh_wrapped, + &fresh_nonce, + &fresh_tag, + ) + .unwrap(); + db.checkpoint().unwrap(); + + // 4. Jetzt: Führe echtes Rekeying aus (sanctum rekey) + let rekeyed_mnemonic = rekey_container(&container_path, password_v2) + .expect("Rekeying muss erfolgreich durchlaufen"); + assert_ne!( + initial_mnemonic, rekeyed_mnemonic, + "Neues Notfallblatt muss sich unterscheiden" + ); + + // 5. Container lässt sich mit neuem Passwort öffnen und Daten sind intakt + let rekeyed_db = Database::open(&container_path).unwrap(); + let rekeyed_meta = rekeyed_db.read_meta().unwrap(); + let rekeyed_kek = derive_kek( + password_v2, + &rekeyed_meta.kdf_salt, + &rekeyed_meta.kdf_params, + ) + .unwrap(); + let rekeyed_dek = unwrap_dek( + &rekeyed_kek, + &rekeyed_meta.wrapped_dek, + &rekeyed_meta.header_nonce, + &rekeyed_meta.header_tag, + ) + .unwrap(); + assert_ne!( + *dek, *rekeyed_dek, + "DEK muss nach Rekeying erneuert worden sein" + ); + + let rekeyed_fs = SanctumFs::new( + rekeyed_db.clone(), + rekeyed_dek.clone(), + rekeyed_meta.version, + ); + let mut check_opts = OpenOptions::default(); + check_opts.read = true; + let mut check_f = rekeyed_fs.open(&secret_path, check_opts).await.unwrap(); + let rekeyed_data = check_f.read_bytes(secret_data.len()).await.unwrap(); + assert_eq!( + &rekeyed_data[..], + secret_data, + "Daten müssen nach Rekeying vollständig intakt sein" + ); + drop(check_f); + drop(rekeyed_fs); + + // Integritätsprüfung nach Rekeying + let report = verify_container(&container_path, Some(&rekeyed_dek), true).unwrap(); + assert!( + report.is_healthy(), + "Container muss nach Rekeying gesunden Status aufweisen" + ); + assert_eq!(report.corrupted_chunks, 0); + + // 6. Altes Notfallblatt ist entwertet: Kann umverschlüsselte Chunks NICHT mehr entschlüsseln! + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + conn.execute("DELETE FROM meta", []).unwrap(); + drop(conn); + + // Versuch, mit altem Notfallblatt wiederherzustellen + restore_header_from_recovery_key(&container_path, &initial_mnemonic, "OldKeyPassword!") + .unwrap(); + + let old_res_db = Database::open(&container_path).unwrap(); + let old_res_meta = old_res_db.read_meta().unwrap(); + let old_res_kek = derive_kek( + "OldKeyPassword!", + &old_res_meta.kdf_salt, + &old_res_meta.kdf_params, + ) + .unwrap(); + let old_res_dek = unwrap_dek( + &old_res_kek, + &old_res_meta.wrapped_dek, + &old_res_meta.header_nonce, + &old_res_meta.header_tag, + ) + .unwrap(); + + // Entschlüsselung der Datei mit dem alten DEK muss scheitern (AEAD tag mismatch) + let old_fs = SanctumFs::new( + old_res_db.clone(), + old_res_dek.clone(), + old_res_meta.version, + ); + let mut fail_opts = OpenOptions::default(); + fail_opts.read = true; + let mut fail_f = old_fs.open(&secret_path, fail_opts).await.unwrap(); + let fail_res = fail_f.read_bytes(secret_data.len()).await; + assert!( + fail_res.is_err(), + "Altes Notfallblatt darf nach Rekeying Chunks nicht mehr entschlüsseln können!" + ); + + // Auch verify_container mit altem DEK meldet Korruption / MAC-Fehlschlag + let fail_report = verify_container(&container_path, Some(&old_res_dek), true).unwrap(); + assert!( + !fail_report.is_healthy(), + "Prüfung mit altem Notfallschlüssel muss Fehler melden" + ); + } + + // Aufräumen + let _ = std::fs::remove_file(&container_path); +}