diff --git a/src/crypto.rs b/src/crypto.rs index 11b7082..c7cc418 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -146,6 +146,7 @@ pub fn generate_dummy_slot() -> (Vec, [u8; 12], [u8; 16], [u8; 16]) { /// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM. /// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind. +/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext). pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); @@ -154,16 +155,16 @@ pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { let tag = cipher .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer) .expect("Name encryption"); - format!( - "$h${}${}${}", - hex::encode(nonce_bytes), - hex::encode(tag.as_slice()), - hex::encode(&buffer) - ) + let mut combined = Vec::with_capacity(12 + 16 + buffer.len()); + combined.extend_from_slice(&nonce_bytes); + combined.extend_from_slice(tag.as_slice()); + combined.extend_from_slice(&buffer); + hex::encode(combined) } /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option { + // Abwärtskompatibilität für alte v0.2.0 $h$$$ Namen if let Some(rest) = stored.strip_prefix("$h$") { let parts: Vec<&str> = rest.split('$').collect(); if parts.len() == 3 { @@ -189,6 +190,33 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option { } } } + return None; + } + + // Neuer Standard: Reiner Hex-String ohne Präfix (12B Nonce + 16B Tag + Ciphertext) + if stored.len() >= 56 { + if let Ok(bytes) = hex::decode(stored) { + if bytes.len() >= 28 { + let nonce = &bytes[0..12]; + let tag = &bytes[12..28]; + let ct = &bytes[28..]; + + if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) { + let mut buffer = ct.to_vec(); + if cipher + .decrypt_in_place_detached( + Nonce::from_slice(nonce), + b"SANCTUM_NODE_NAME", + &mut buffer, + Tag::from_slice(tag), + ) + .is_ok() + { + return String::from_utf8(buffer).ok(); + } + } + } + } } None } @@ -482,12 +510,19 @@ mod tests { let dek = generate_dek(); let filename = "ultra_geheimes_dokument.pdf"; let encrypted = encrypt_node_name(&dek, filename); - assert!(encrypted.starts_with("$h$")); + // Kein verräterisches Präfix mehr! Reines Hex. + assert!(!encrypted.starts_with("$h$")); assert!(!encrypted.contains(filename)); + assert!(encrypted.len() >= 56); let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name"); assert_eq!(decrypted, filename); + // Abwärtskompatibilität: Legacy $h$$$ Format muss weiter entschlüsselt werden + let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]); + let decrypted_legacy = decrypt_node_name(&dek, &legacy_format).expect("Decrypt legacy $h$ name"); + assert_eq!(decrypted_legacy, filename); + // Mit anderem DEK schlägt Entschlüsselung fehl let other_dek = generate_dek(); assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); diff --git a/src/main.rs b/src/main.rs index e68035a..214ef9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,8 @@ use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use sanctum::crypto::{ - dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek, - wrap_dek, KdfParams, FORMAT_VERSION, + dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek, + KdfParams, FORMAT_VERSION, }; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::recovery::{ @@ -425,23 +425,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .read_meta() .context("Konnte Container-Header nicht lesen")?; - ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort..."); - let mut found = None; - for slot in &meta.slots { - if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(d) = unwrap_dek( - &old_kek, - &slot.wrapped_dek, - &slot.header_nonce, - &slot.header_tag, - ) { - found = Some((d, slot.slot_id)); - break; - } - } - } - - found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))? + ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)..."); + let (d, _ver, slot_id) = meta + .authenticate(&old_password) + .ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?; + (d, slot_id) }; println!(); @@ -592,17 +580,9 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { .context("Konnte Container-Datenbank nicht öffnen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; - let mut found = None; - for slot in &meta.slots { - if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { - found = Some((dek, slot.slot_id)); - break; - } - } - } - - let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; + let (dek, _ver, slot_id) = meta + .authenticate(&password) + .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; let phrase = dek_to_mnemonic(&dek)?; if slot_id == 1 { @@ -631,19 +611,10 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { let db = Database::open(container_path).context("Konnte Container nicht öffnen")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?; - let mut found = None; - for slot in &meta.slots { - if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { - found = Some(d); - break; - } - } - } - - match found { - Some(d) => { - println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔")); + match meta.authenticate(&password) { + Some((d, _ver, slot_id)) => { + let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" }; + println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc)); Some(d) } None => { diff --git a/src/mount.rs b/src/mount.rs index 19a21ef..dd55230 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -14,7 +14,7 @@ use tokio::net::TcpListener; use tokio::sync::watch; use tracing::{debug, warn}; -use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek}; +use crate::crypto::mnemonic_to_dek; use crate::storage::Database; use crate::ui; use crate::vfs::SanctumFs; @@ -109,24 +109,8 @@ pub async fn mount_container( let (dek, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { - ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); - let mut unwrapped = None; - - for slot in &meta.slots { - if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(dek) = unwrap_dek( - &kek, - &slot.wrapped_dek, - &slot.header_nonce, - &slot.header_tag, - ) { - unwrapped = Some((dek, slot.version, slot.slot_id)); - break; - } - } - } - - match unwrapped { + ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); + match meta.authenticate(password) { Some((dek, ver, slot_id)) => { ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); (dek, ver, slot_id) @@ -142,15 +126,12 @@ pub async fn mount_container( .context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); - let vault_id = if db.has_hidden_vault().unwrap_or(false) { - let is_hidden = { - let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); - !children.is_empty() - }; - if is_hidden { 1 } else { 0 } - } else { - 0 + // Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann + let is_hidden = { + let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); + !children.is_empty() }; + let vault_id = if is_hidden { 1 } else { 0 }; (dek, meta.version, vault_id) } }; diff --git a/src/recovery.rs b/src/recovery.rs index b4fe1b5..794b5ef 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -13,6 +13,18 @@ use crate::storage::{ContainerMeta, Database, SlotMeta}; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const CURRENT_BACKUP_VERSION: u32 = 1; +/// Struktur für einen gesicherten Header-Slot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotBackup { + pub slot_id: u32, + pub 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, +} + /// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeaderBackup { @@ -24,6 +36,8 @@ pub struct HeaderBackup { pub wrapped_dek_hex: String, pub header_nonce_hex: String, pub header_tag_hex: String, + #[serde(default)] + pub slots: Vec, pub created_at: u64, } @@ -34,6 +48,20 @@ impl HeaderBackup { .map(|d| d.as_secs()) .unwrap_or(0); + let slots: Vec = meta + .slots + .iter() + .map(|s| SlotBackup { + slot_id: s.slot_id, + version: s.version, + kdf_salt_hex: hex::encode(s.kdf_salt), + kdf_params: s.kdf_params.clone(), + wrapped_dek_hex: hex::encode(&s.wrapped_dek), + header_nonce_hex: hex::encode(s.header_nonce), + header_tag_hex: hex::encode(s.header_tag), + }) + .collect(); + Self { magic: HEADER_BACKUP_MAGIC.to_string(), backup_version: CURRENT_BACKUP_VERSION, @@ -43,6 +71,7 @@ impl HeaderBackup { wrapped_dek_hex: hex::encode(&meta.wrapped_dek), header_nonce_hex: hex::encode(meta.header_nonce), header_tag_hex: hex::encode(meta.header_tag), + slots, created_at: now, } } @@ -52,51 +81,97 @@ impl HeaderBackup { 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 slots = Vec::new(); + + if !self.slots.is_empty() { + for s in &self.slots { + let salt_bytes = hex::decode(&s.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(&s.wrapped_dek_hex) + .context("Ungültige Hex-Kodierung für wrapped_dek")?; + + let nonce_bytes = hex::decode(&s.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(&s.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); + + slots.push(SlotMeta { + slot_id: s.slot_id, + version: s.version, + kdf_salt, + kdf_params: s.kdf_params.clone(), + wrapped_dek, + header_nonce, + header_tag, + }); + } + } else { + // Fallback für alte Backups ohne slots-Array + 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); + + let slot0 = SlotMeta { + slot_id: 0, + version: self.container_format_version, + kdf_salt, + kdf_params: self.kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce, + header_tag, + }; + slots.push(slot0); } - 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); - - let slot0 = SlotMeta { - slot_id: 0, - version: self.container_format_version, - kdf_salt, - kdf_params: self.kdf_params.clone(), - wrapped_dek: wrapped_dek.clone(), - header_nonce, - header_tag, - }; + let slot0 = &slots[0]; Ok(ContainerMeta { - version: self.container_format_version, - kdf_salt, - kdf_params: self.kdf_params.clone(), - wrapped_dek, - header_nonce, - header_tag, - slots: vec![slot0], + version: slot0.version, + kdf_salt: slot0.kdf_salt, + kdf_params: slot0.kdf_params.clone(), + wrapped_dek: slot0.wrapped_dek.clone(), + header_nonce: slot0.header_nonce, + header_tag: slot0.header_tag, + slots, }) } } @@ -187,6 +262,17 @@ pub fn restore_header_from_recovery_key( header_tag, }; + // 4. In Container schreiben (bestehenden Slot 1 wie Hidden Vault oder Dummy bewahren) + let db = Database::open(container_path) + .context("Konnte Container-Datenbank nicht öffnen")?; + + let mut slots = vec![slot0]; + if let Ok(existing_slots) = db.read_slots() { + if let Some(s1) = existing_slots.into_iter().find(|s| s.slot_id == 1) { + slots.push(s1); + } + } + let meta = ContainerMeta { version: FORMAT_VERSION, kdf_salt: salt, @@ -194,13 +280,9 @@ pub fn restore_header_from_recovery_key( wrapped_dek, header_nonce, header_tag, - slots: vec![slot0], + slots, }; - // 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")?; diff --git a/src/storage.rs b/src/storage.rs index c97d42f..df11801 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -8,9 +8,10 @@ use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use crate::crypto::{ - decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION, - FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, + decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams, + FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, }; +use zeroize::Zeroizing; #[allow(dead_code)] #[derive(Debug, Clone)] @@ -57,6 +58,28 @@ pub struct ContainerMeta { pub slots: Vec, } +impl ContainerMeta { + /// Authentifiziert ein Master-Passwort über alle Header-Slots in strikt konstanter Zeit (Anti-Timing Side-Channel). + /// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch. + /// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id). + pub fn authenticate(&self, password: &str) -> Option<(Zeroizing<[u8; 32]>, u32, u32)> { + let mut matching = None; + for slot in &self.slots { + let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) + .ok() + .and_then(|kek| { + unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok() + }); + if let Some(dek) = res { + if matching.is_none() { + matching = Some((dek, slot.version, slot.slot_id)); + } + } + } + matching + } +} + #[derive(Clone)] pub struct Database { conn: Arc>, @@ -97,7 +120,13 @@ impl Database { self.conn.lock().unwrap() } - /// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalten slot_id und vault_id, auto_vacuum) durch. + /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit. + pub fn authenticate_password(&self, password: &str) -> Result, u32, u32)>> { + let meta = self.read_meta()?; + Ok(meta.authenticate(password)) + } + + /// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch. pub fn ensure_schema_upgrades(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0); @@ -106,16 +135,9 @@ impl Database { let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); } - // Spalte slot_id in meta + // Spalte slot_id in meta (falls aus v1 migriert) let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []); - // Spalte vault_id in nodes - let _ = conn.execute("ALTER TABLE nodes ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []); - let _ = conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_vault_parent ON nodes(vault_id, parent_id)", []); - - // Spalte vault_id in chunks - let _ = conn.execute("ALTER TABLE chunks ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []); - Ok(()) } @@ -195,21 +217,11 @@ impl Database { Ok(()) } - /// Prüft, ob ein Hidden Vault (Wurzelknoten 2 mit vault_id = 1) im Container existiert. - pub fn has_hidden_vault(&self) -> Result { - let conn = self.conn.lock().unwrap(); - let exists: bool = conn - .query_row( - "SELECT 1 FROM nodes WHERE id = 2 AND vault_id = 1 LIMIT 1", - [], - |_| Ok(true), - ) - .optional()? - .unwrap_or(false); - Ok(exists) - } - /// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault). + /// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur: + /// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen) + /// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1) + /// - Keinerlei Klartext-Kennzeichnungen wie `vault_id` in SQLite pub fn init_schema_with_hidden( &self, salt: &[u8; 16], @@ -235,7 +247,6 @@ impl Database { CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, - vault_id INTEGER NOT NULL DEFAULT 0, parent_id INTEGER, name TEXT NOT NULL, is_dir INTEGER NOT NULL, @@ -244,11 +255,10 @@ impl Database { modified_at INTEGER NOT NULL, FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(vault_id, COALESCE(parent_id, 0), name); + CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL; CREATE TABLE IF NOT EXISTS chunks ( node_id INTEGER NOT NULL, - vault_id INTEGER NOT NULL DEFAULT 0, chunk_index INTEGER NOT NULL, nonce BLOB NOT NULL, tag BLOB NOT NULL, @@ -274,11 +284,16 @@ impl Database { ], )?; - // Wurzelknoten '/' für Vault 0 (id = 1, vault_id = 0) anlegen + // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur) let now = current_timestamp(); conn.execute( - "INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) - VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)", + "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (1, NULL, '', 1, 0, ?1, ?2)", + params![now, now], + )?; + conn.execute( + "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (2, NULL, '', 1, 0, ?1, ?2)", params![now, now], )?; @@ -298,13 +313,6 @@ impl Database { h_tag.as_slice(), ], )?; - - // Wurzelknoten '/' für Hidden Vault (id = 2, vault_id = 1) anlegen - conn.execute( - "INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) - VALUES (2, 1, NULL, '', 1, 0, ?1, ?2)", - params![now, now], - )?; } else { // Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); @@ -509,7 +517,7 @@ impl Database { let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes - WHERE vault_id = 0 AND parent_id = ?1 AND name = ?2", + WHERE parent_id = ?1 AND name = ?2", )?; let record: Option = stmt @@ -541,7 +549,7 @@ impl Database { let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes - WHERE vault_id = 1 AND parent_id = ?1", + WHERE parent_id = ?1", )?; let rows = stmt.query_map(params![current_id], |row| { @@ -604,11 +612,11 @@ impl Database { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at - FROM nodes WHERE id = ?1 AND vault_id = ?2", + FROM nodes WHERE id = ?1", )?; let record = stmt - .query_row(params![id, vault_id], |row| { + .query_row(params![id], |row| { let enc_name: String = row.get(2)?; let name = if vault_id == 1 { decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) @@ -646,11 +654,11 @@ impl Database { let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes - WHERE vault_id = ?1 AND parent_id = ?2 + WHERE parent_id = ?1 ORDER BY is_dir DESC, id ASC", )?; - let rows = stmt.query_map(params![vault_id, parent_id], |row| { + let rows = stmt.query_map(params![parent_id], |row| { let enc_name: String = row.get(2)?; let name = if vault_id == 1 { decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) @@ -700,9 +708,9 @@ impl Database { }; conn.execute( - "INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at) - VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", - params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now], + "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) + VALUES (?1, ?2, ?3, 0, ?4, ?5)", + params![parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now], )?; let new_id = conn.last_insert_rowid(); @@ -927,8 +935,12 @@ impl Database { conn.execute("DELETE FROM meta", [])?; + let mut has_slot1 = false; if !meta.slots.is_empty() { for slot in &meta.slots { + if slot.slot_id == 1 { + has_slot1 = true; + } let params_json = serde_json::to_string(&slot.kdf_params)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) @@ -960,8 +972,10 @@ impl Database { meta.header_tag.as_slice(), ], )?; + } - // Dummy-Slot 1 für Plausible Deniability generieren + // Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability + if !has_slot1 { let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let dummy_params_json = serde_json::to_string(&KdfParams::default())?; conn.execute( @@ -1234,8 +1248,6 @@ mod tests { Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), ).unwrap(); - assert!(db.has_hidden_vault().unwrap()); - // Slots prüfen let slots = db.read_slots().unwrap(); assert_eq!(slots.len(), 2); @@ -1268,7 +1280,7 @@ mod tests { let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap(); assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!"); - // Forensische Prüfung: Roh-Inspektion der SQLite-Tabelle `nodes` + // Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen let conn = db.conn.lock().unwrap(); let raw_name_v0: String = conn.query_row( "SELECT name FROM nodes WHERE id = ?1", @@ -1282,7 +1294,23 @@ mod tests { params![hidden_file.id], |r| r.get(0), ).unwrap(); - assert!(raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname muss verschlüsselt mit $h$ beginnen"); + // Plausible Deniability: Kein $h$-Präfix, kein Klartext + assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen"); assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); + + // Keine Spalte `vault_id` in nodes oder chunks + let has_vault_id_nodes: i64 = conn.query_row( + "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ).unwrap(); + assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren"); + + let has_vault_id_chunks: i64 = conn.query_row( + "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ).unwrap(); + assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren"); } } diff --git a/src/verify.rs b/src/verify.rs index cd42015..0f522c1 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -110,7 +110,7 @@ pub fn verify_container( node_map.insert(node.id, node.clone()); } - // Root-Knoten prüfen (id = 1) + // Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault) match node_map.get(&1) { Some(root) => { if !root.is_dir { @@ -125,9 +125,18 @@ pub fn verify_container( } } + if let Some(root2) = node_map.get(&2) { + if !root2.is_dir { + report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string()); + } + if root2.parent_id.is_some() { + report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string()); + } + } + // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen for node in &all_nodes { - if node.id == 1 { + if node.id == 1 || node.id == 2 { continue; } @@ -174,6 +183,54 @@ pub fn verify_container( } } + // Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1) + let mut vault0_nodes = HashSet::new(); + let mut vault1_nodes = HashSet::new(); + vault0_nodes.insert(1i64); + if node_map.contains_key(&2) { + vault1_nodes.insert(2i64); + } + + let mut changed = true; + while changed { + changed = false; + for node in &all_nodes { + if let Some(pid) = node.parent_id { + if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) { + vault0_nodes.insert(node.id); + changed = true; + } else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) { + vault1_nodes.insert(node.id); + changed = true; + } + } + } + } + + // Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört + let active_vault_nodes = if let Some(active_dek) = dek { + let is_vault1 = { + let mut found_v1 = false; + for node in &all_nodes { + if node.parent_id == Some(2) { + if crate::crypto::decrypt_node_name(active_dek, &node.name).is_some() { + found_v1 = true; + break; + } + } + } + found_v1 + }; + + if is_vault1 { + Some(&vault1_nodes) + } else { + Some(&vault0_nodes) + } + } else { + None + }; + // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung let chunk_headers = db.list_all_chunk_headers() .context("Fehler beim Abrufen der Chunk-Liste")?; @@ -187,8 +244,9 @@ pub fn verify_container( )); } - if let Some(active_dek) = dek { - if full_chunks { + if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) { + // Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault) + if target_nodes.contains(&node_id) && full_chunks { match db.read_chunk(node_id, chunk_index) { Ok(Some(record)) => { match decrypt_chunk( @@ -283,7 +341,7 @@ mod tests { 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_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos assert_eq!(report.total_chunks, 2); assert_eq!(report.corrupted_chunks, 0); assert_eq!(report.orphan_nodes, 0); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 36a0753..4f49f13 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -792,6 +792,188 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let _ = std::fs::remove_file(&container_path); } +#[tokio::test] +async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() { + let temp_dir = std::env::temp_dir(); + let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id())); + let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id())); + let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id())); + + if path_standard.exists() { + let _ = std::fs::remove_file(&path_standard); + } + if path_dual.exists() { + let _ = std::fs::remove_file(&path_dual); + } + if backup_path.exists() { + let _ = std::fs::remove_file(&backup_path); + } + + let pass_decoy = "OuterDecoyPassphrase2026!"; + let pass_hidden = "InnerHiddenVaultPass2026!"; + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + + // 1. Erstelle Standard-Container (Container A) + let salt_std = generate_salt(); + let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap(); + let dek_std = generate_dek(); + let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap(); + + let db_std = Database::open(&path_standard).unwrap(); + db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap(); + db_std.checkpoint().unwrap(); + + // 2. Erstelle Dual-Vault Container (Container B) + let salt_b0 = generate_salt(); + let salt_b1 = generate_salt(); + let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap(); + let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap(); + let dek_b0 = generate_dek(); + let dek_b1 = generate_dek(); + let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap(); + let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap(); + + let db_dual = Database::open(&path_dual).unwrap(); + db_dual.init_schema_with_hidden( + &salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0, + Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)), + ).unwrap(); + db_dual.checkpoint().unwrap(); + + // 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit + let conn_std = rusqlite::Connection::open(&path_standard).unwrap(); + let conn_dual = rusqlite::Connection::open(&path_dual).unwrap(); + + // A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern + for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] { + let v_nodes: i64 = conn.query_row( + "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0) + ).unwrap(); + assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name); + + let v_chunks: i64 = conn.query_row( + "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0) + ).unwrap(); + assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name); + + // Genau 2 Slots in meta + let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap(); + assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name); + + // Genau 2 Root-Nodes in nodes (id=1 und id=2) + let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap(); + assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name); + } + drop(conn_std); + drop(conn_dual); + + // 4. Dateien in beiden Vaults von Container B anlegen + let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0); + let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1); + + let decoy_path = DavPath::new("/family_recipe.txt").unwrap(); + let hidden_path = DavPath::new("/private_journal.docx").unwrap(); + + let mut opts_w = OpenOptions::default(); + opts_w.write = true; + opts_w.create_new = true; + + let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap(); + f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap(); + f_d.flush().await.unwrap(); + drop(f_d); + + let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap(); + f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap(); + f_h.flush().await.unwrap(); + drop(f_h); + + db_dual.checkpoint().unwrap(); + + // 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite + let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap(); + let hidden_node_name: String = conn_dual2.query_row( + "SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0) + ).unwrap(); + assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!"); + assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!"); + assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein"); + + let count_dollar_h: i64 = conn_dual2.query_row( + "SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0) + ).unwrap(); + assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren"); + drop(conn_dual2); + + // 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG + let meta_dual = db_dual.read_meta().unwrap(); + let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy"); + assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren"); + assert_eq!(*auth_decoy.0, *dek_b0); + + let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden"); + assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren"); + assert_eq!(*auth_hidden.0, *dek_b1); + + assert!(meta_dual.authenticate("WrongPassword123!").is_none()); + + // 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT + // Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden! + let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap(); + assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors); + assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden"); + assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden"); + + // Hidden-Prüfung: Ebenfalls gesund! + let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap(); + assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors); + assert_eq!(report_hidden.corrupted_chunks, 0); + assert_eq!(report_hidden.orphan_nodes, 0); + + // 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten! + export_header_backup(&path_dual, &backup_path).unwrap(); + + // Zerstöre Header + let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap(); + conn_wipe.execute("DELETE FROM meta", []).unwrap(); + drop(conn_wipe); + + // Stelle wieder her + restore_header_backup(&path_dual, &backup_path).unwrap(); + + // Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos! + let restored_meta = db_dual.read_meta().unwrap(); + let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore"); + assert_eq!(res_decoy.2, 0); + let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore"); + assert_eq!(res_hidden.2, 1); + + // 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört! + let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap(); + let brand_new_decoy_pass = "BrandNewDecoyPassword2026!"; + restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap(); + + let rescued_meta = db_dual.read_meta().unwrap(); + // Neues Decoy Passwort entsperrt Slot 0 + let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass"); + assert_eq!(rescued_decoy.2, 0); + assert_eq!(*rescued_decoy.0, *dek_b0); + + // Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!) + let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved"); + assert_eq!(rescued_hidden.2, 1); + assert_eq!(*rescued_hidden.0, *dek_b1); + + // Aufräumen + let _ = std::fs::remove_file(&path_standard); + let _ = std::fs::remove_file(&path_dual); + let _ = std::fs::remove_file(&backup_path); +} +