use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{bail, Result}; use rand::rngs::OsRng; use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use tracing::{error, warn}; use crate::crypto::{ compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key, encrypt_node_name, generate_dummy_slot, unwrap_key_payload, validate_kdf_params, verify_metadata_mac, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3, MAGIC_BYTES, }; use zeroize::Zeroizing; #[allow(dead_code)] #[derive(Debug, Clone)] pub struct NodeRecord { pub id: i64, pub parent_id: Option, pub name: String, pub is_dir: bool, pub size: u64, pub created_at: u64, pub modified_at: u64, } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct ChunkRecord { pub node_id: i64, pub chunk_index: u32, pub generation: u64, pub nonce: [u8; 12], pub tag: [u8; 16], pub ciphertext: Vec, } #[derive(Debug, Clone)] pub struct SlotMeta { pub slot_id: u32, pub version: u32, pub kdf_salt: [u8; 16], pub kdf_params: KdfParams, pub wrapped_dek: Vec, pub header_nonce: [u8; 12], pub header_tag: [u8; 16], } /// Ergebnis einer erfolgreichen Authentifizierung eines Container-Slots. /// Die Tupel-Struktur (0: DEK, 1: Version, 2: Slot-ID, 3: Carrier-DEK, 4: Carrier-Node-ID) /// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`). #[derive(Clone)] pub struct UnlockedKeys( pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1) pub u32, // 1: Formatversion pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault) pub Option>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden) pub Option, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes) ); /// RAII-Guard für den Advisory-Lock eines gemounteten Containers (S-06). pub struct AdvisoryLockGuard { db: Database, } impl Drop for AdvisoryLockGuard { fn drop(&mut self) { let _ = self.db.release_advisory_lock(); } } impl UnlockedKeys { pub fn dek(&self) -> &Zeroizing<[u8; 32]> { &self.0 } pub fn version(&self) -> u32 { self.1 } pub fn slot_id(&self) -> u32 { self.2 } pub fn carrier_dek(&self) -> Option> { self.3.clone() } pub fn carrier_node_id(&self) -> Option { self.4 } } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct ContainerMeta { pub version: u32, pub kdf_salt: [u8; 16], pub kdf_params: KdfParams, pub wrapped_dek: Vec, pub header_nonce: [u8; 12], pub header_tag: [u8; 16], 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 { // SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek) // Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten if self.slots.is_empty() || self.slots.len() > 2 { return None; } if !self.slots.iter().any(|s| s.slot_id == 0) { return None; } if self.slots.iter().any(|s| s.slot_id > 1) { return None; } let mut seen_ids = std::collections::HashSet::new(); for slot in &self.slots { if !seen_ids.insert(slot.slot_id) { return None; } if validate_kdf_params(&slot.kdf_params).is_err() { return None; } } 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_key_payload( &kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag, ) .ok() }); if let Some(payload) = res { if matching.is_none() { if slot.slot_id == 0 { let mut dek = Zeroizing::new([0u8; 32]); let carrier_node_id = if payload.len() >= 40 { dek.copy_from_slice(&payload[0..32]); let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap()); if cid > 0 { Some(cid) } else { None } } else if payload.len() >= 32 { dek.copy_from_slice(&payload[0..32]); None } else { continue; }; matching = Some(UnlockedKeys(dek, slot.version, 0, None, carrier_node_id)); } else if slot.slot_id == 1 { let mut dek_1 = Zeroizing::new([0u8; 32]); let mut dek_0 = Zeroizing::new([0u8; 32]); let (carrier_dek, carrier_node_id) = if payload.len() >= 72 { dek_1.copy_from_slice(&payload[0..32]); dek_0.copy_from_slice(&payload[32..64]); let cid = i64::from_le_bytes(payload[64..72].try_into().unwrap()); (Some(dek_0), if cid > 0 { Some(cid) } else { None }) } else if payload.len() >= 64 { dek_1.copy_from_slice(&payload[0..32]); dek_0.copy_from_slice(&payload[32..64]); (Some(dek_0), None) } else if payload.len() >= 32 { dek_1.copy_from_slice(&payload[0..32]); (None, None) } else { continue; }; matching = Some(UnlockedKeys( dek_1, slot.version, 1, carrier_dek, carrier_node_id, )); } } } } matching } } #[derive(Clone)] pub struct Database { conn: Arc>, active_session: Arc)>>>, } fn current_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MetadataMacStatus { /// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten überein. Valid, /// Container-Header wurde frisch aus einem Backup restauriert (metadata_gen == 0 && metadata_mac IS NULL). /// Der MAC muss beim Mounten transparent mit dem aktiven DEK neu aufgebaut werden. PendingRebuild, /// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht). Invalid, } impl Database { /// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas. pub fn open>(path: P) -> Result { let path_ref = path.as_ref(); let conn = match Connection::open(path_ref) { Ok(c) => c, Err(e) => { let err_str = e.to_string(); if err_str.contains("Access is denied") || err_str.contains("permission denied") || err_str.contains("os error 5") { bail!( "Zugriff auf '{}' verweigert (OS Fehler 5 / Access Denied).\n\ [!] Möglicherweise blockiert durch Windows Defender 'Überwachter Ordnerzugriff' (Controlled Folder Access).\n\ [i] Abhilfe: Fügen Sie 'sanctum.exe' in den Windows-Sicherheitseinstellungen (Viren- & Bedrohungsschutz -> Ransomware-Schutz -> Überwachter Ordnerzugriff) als erlaubte App hinzu, oder platzieren Sie den Container außerhalb geschützter Benutzerordner.", path_ref.display() ); } return Err(e.into()); } }; // S-04: Untrusted Container & Fremddatei-Schutz. // Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden: // Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen. let table_count: i64 = conn .query_row( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';", [], |r| r.get(0), ) .unwrap_or(0); if table_count > 0 { let has_sanctum_schema: bool = { let has_meta: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;", [], |_| Ok(()), ) .optional()? .is_some(); if !has_meta { false } else { let magic_opt: Option>> = conn .query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0)) .optional()?; match magic_opt { Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(), Some(None) => false, None => { // Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung). // Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist, // um Fremddatenbanken weiterhin strikt abzuweisen. let has_nodes: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;", [], |_| Ok(()), ) .optional()? .is_some(); let has_chunks: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;", [], |_| Ok(()), ) .optional()? .is_some(); has_nodes && has_chunks } } } }; if !has_sanctum_schema { bail!( "Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.", path_ref.display() ); } // SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS) let has_meta_table: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;", [], |_| Ok(()), ) .optional()? .is_some(); if has_meta_table { let slot_count: i64 = conn .query_row("SELECT count(*) FROM meta;", [], |r| r.get(0)) .unwrap_or(0); if slot_count > 2 { bail!( "Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)", slot_count ); } } } let db = Self { conn: Arc::new(Mutex::new(conn)), active_session: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; if table_count > 0 { db.ensure_schema_upgrades()?; } Ok(db) } /// Öffnet eine In-Memory-Datenbank (vorwiegend für Tests). #[cfg(test)] pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory()?; let db = Self { conn: Arc::new(Mutex::new(conn)), active_session: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; db.ensure_schema_upgrades()?; Ok(db) } /// Erwirbt den Mutex auf die SQLite-Verbindung und fängt eventuelles Mutex-Poisoning ab (Z-02). pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn.lock().unwrap_or_else(|e| e.into_inner()) } pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn() } /// Ermittelt den physischen Dateipfad des geöffneten Containers aus SQLite (sofern nicht in-memory). pub fn container_path(&self) -> Option { let conn = self.conn(); let path_str: String = conn .query_row("PRAGMA database_list;", [], |r| r.get(2)) .ok()?; if path_str.is_empty() { None } else { Some(std::path::PathBuf::from(path_str)) } } /// Ermittelt die physische Dateigröße des Containers über die SQLite-Page-Statistik (Z-04). pub fn get_container_file_size(&self) -> Result { let conn = self.conn(); let page_count: i64 = conn.query_row("PRAGMA page_count;", [], |r| r.get(0))?; let page_size: i64 = conn.query_row("PRAGMA page_size;", [], |r| r.get(0))?; Ok((page_count.saturating_mul(page_size)).max(0) as u64) } /// Ermittelt die kumulierte Dateigröße aller im Container gespeicherten Nutzdateien (Z-04). pub fn get_total_used_size(&self) -> Result { let conn = self.conn(); let total: i64 = conn.query_row( "SELECT COALESCE(SUM(size), 0) FROM nodes WHERE is_dir = 0", [], |r| r.get(0), )?; Ok(total.max(0) as u64) } /// Erzeugt eine geklonte Instanz mit einer isolierten aktiven Session (Slot & DEK). pub fn with_session(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) -> Self { Self { conn: self.conn.clone(), active_session: Arc::new(Mutex::new(Some((slot_id, dek)))), } } /// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01) auf Slot 0. pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) { self.set_active_slot_and_dek(0, dek); } /// Setzt den aktiven Slot und DEK für automatische Metadaten-Authentifizierung (K-01). pub fn set_active_slot_and_dek(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) { *self .active_session .lock() .unwrap_or_else(|e| e.into_inner()) = Some((slot_id, dek)); } /// Gibt den aktuellen aktiven DEK zurück, falls gesetzt. pub fn active_dek(&self) -> Option> { self.active_session .lock() .unwrap_or_else(|e| e.into_inner()) .as_ref() .map(|(_, dek)| dek.clone()) } /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit. pub fn authenticate_password(&self, password: &str) -> Result> { let meta = self.read_meta()?; let res = meta.authenticate(password); if let Some(ref keys) = res { self.set_active_slot_and_dek(keys.slot_id(), keys.dek().clone()); if let Some(cid) = keys.carrier_node_id() { let _ = self.mark_carrier_node_id(cid); } } Ok(res) } /// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch. pub fn ensure_schema_upgrades(&self) -> Result<()> { let conn = self.conn(); let av: i64 = conn .query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)) .unwrap_or(0); if av != 2 { // Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); } // 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 is_carrier in nodes (S-03 Carrier-Schutz) let _ = conn.execute( "ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", [], ); // Spalte metadata_mac in meta (Format V3 / K-01) let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []); // Spalte metadata_gen in meta (Format V3 / K-01) let _ = conn.execute( "ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0", [], ); // Spalte generation in chunks (Format V3 / K-02) let _ = conn.execute( "ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0", [], ); // Spalten für Advisory-Lock (S-06) let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_pid INTEGER", []); let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_host TEXT", []); let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_time INTEGER", []); Ok(()) } /// Setzt die vorgeschriebenen SQLite3-Pragmas: 8192 Page-Size, Incremental Auto-Vacuum, WAL, NORMAL synchronous, Secure Delete. pub fn init_pragmas(&self) -> Result<()> { let conn = self.conn(); conn.execute_batch( "PRAGMA page_size = 8192; PRAGMA auto_vacuum = INCREMENTAL; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA busy_timeout = 5000;", )?; Ok(()) } /// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben. pub fn incremental_vacuum(&self, pages: Option) -> Result { let conn = self.conn(); let before: i64 = conn .query_row("PRAGMA freelist_count;", [], |r| r.get(0)) .unwrap_or(0); if before <= 0 { return Ok(0); } let pragma_sql = match pages { Some(n) => format!("PRAGMA incremental_vacuum({});", n), None => "PRAGMA incremental_vacuum;".to_string(), }; let mut stmt = conn.prepare(&pragma_sql)?; let mut rows = stmt.query([])?; let mut stepped = 0; while let Some(_) = rows.next()? { stepped += 1; } drop(rows); drop(stmt); let after: i64 = conn .query_row("PRAGMA freelist_count;", [], |r| r.get(0)) .unwrap_or(0); let actual_freed = (before - after).max(0) as usize; Ok(actual_freed.max(stepped)) } /// Gibt die Anzahl ungenutzter Freelist-Seiten zurück. pub fn freelist_count(&self) -> Result { let conn = self.conn(); let count: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0))?; Ok(count as usize) } /// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03). pub fn find_carrier_node_id(&self) -> Result> { let conn = self.conn(); let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?; let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?; Ok(id) } /// Markiert einen existierenden Knoten explizit als Carrier. pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> { let conn = self.conn(); conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?; Ok(()) } /// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist. pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result { if node_id == ancestor_id { return Ok(true); } let conn = self.conn(); let mut stmt = conn.prepare( "WITH RECURSIVE sub(id) AS ( SELECT id FROM nodes WHERE id = ?1 UNION ALL SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id ) SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;", )?; let exists: Option = stmt .query_row(params![ancestor_id, node_id], |r| r.get(0)) .optional()?; Ok(exists.is_some()) } /// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding). /// /// SA-07 / Technischer Hinweis zu sicherem Löschen: /// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records /// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor /// logischer Wiederherstellung auf Dateisystem- und Datenbankebene. /// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) /// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) /// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren /// neuen Flash-Speicherzellen zuweisen. pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { let conn = self.conn(); let mut stmt = conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; let chunks: Vec<(u32, usize)> = stmt .query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))? .filter_map(|r| r.ok()) .collect(); for (chunk_idx, ct_len) in chunks { let mut noise = vec![0u8; ct_len]; let mut nonce_noise = [0u8; 12]; let mut tag_noise = [0u8; 16]; OsRng.fill_bytes(&mut noise); OsRng.fill_bytes(&mut nonce_noise); OsRng.fill_bytes(&mut tag_noise); let _ = conn.execute( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", params![nonce_noise.as_slice(), tag_noise.as_slice(), noise, node_id, chunk_idx], ); } Ok(()) } /// Initialisiert das Datenbankschema für Modell A (Alibi-Carrier / Steganografischer Tresor). /// Legt die Trägerdatei im Decoy-Vault an und allokiert alle Carrier-Chunks mit initialem Rauschen. /// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur: /// - Slot 0: 40 Bytes gewrappter Payload (32B DEK_0 || 8B carrier_node_id) /// - Slot 1: 72 Bytes gewrappter Payload (32B DEK_1 || 32B DEK_0 || 8B carrier_node_id oder CSPRNG-Rauschen) /// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1) /// - 0 unzugeordnete Chunks: 100% aller Chunks gehören zu legitimen Decoy-Inodes und authentifizieren fehlerfrei unter DEK_0! pub fn init_schema_with_carrier( &self, salt_0: &[u8; 16], kdf_params_0: &KdfParams, wrapped_dek_0: &[u8], header_nonce_0: &[u8; 12], header_tag_0: &[u8; 16], carrier_config: Option<( &str, // carrier_name u64, // carrier_size_bytes &[u8; 16], // salt_1 &KdfParams, // kdf_params_1 &[u8], // wrapped_dek_1 (72B) &[u8; 12], // header_nonce_1 &[u8; 16], // header_tag_1 &[u8; 32], // raw DEK_0 &[u8; 32], // raw DEK_1 )>, ) -> Result> { let conn = self.conn(); conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta ( slot_id INTEGER NOT NULL PRIMARY KEY, 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, metadata_mac BLOB, metadata_gen INTEGER NOT NULL DEFAULT 0, lock_pid INTEGER, lock_host TEXT, lock_time INTEGER ); CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER, name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, modified_at INTEGER NOT NULL, is_carrier INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE ); 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, chunk_index INTEGER NOT NULL, generation INTEGER NOT NULL DEFAULT 0, nonce BLOB NOT NULL, tag BLOB NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY (node_id, chunk_index), FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE );", )?; // Slot 0 einfügen (Standard / Decoy Vault) let params_json_0 = serde_json::to_string(kdf_params_0)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, salt_0.as_slice(), params_json_0, wrapped_dek_0, header_nonce_0.as_slice(), header_tag_0.as_slice(), ], )?; let now = current_timestamp(); // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur) conn.execute( "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], )?; let carrier_node_id = if let Some(( c_name, c_size, h_salt, h_params, h_wrapped, h_nonce, h_tag, dek_0, dek_1, )) = carrier_config { // Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen (is_carrier = 1) conn.execute( "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier) VALUES (1, ?1, 0, ?2, ?3, ?4, 1)", params![c_name, c_size as i64, now, now], )?; let c_id = conn.last_insert_rowid(); // Berechne Blockanzahl (min. 3 Blöcke: Block 0/1 für Superblock, Block 2 für Inode-Seite) let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(3) as u32; // Slot 1 (Hidden Vault) einfügen let params_json_1 = serde_json::to_string(h_params)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, h_salt.as_slice(), params_json_1, h_wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], )?; // Initialisiere Carrier-Format V2 (Superblock auf Block 0/1, Inode-Seite auf Block 2) let manifest = crate::carrier::CarrierManifest::new(total_blocks); // 1. Block 2 schreiben: Erste Inode-Tabellenseite mit Wurzelverzeichnis '/' let root_page = crate::carrier::CarrierInodePage { magic: *crate::carrier::CARRIER_PAGE_MAGIC, entries: manifest.inodes.values().cloned().collect(), }; let root_page_bytes = serde_json::to_vec(&root_page)?; let (inner_ct_2, inner_nonce_2, inner_tag_2) = crate::crypto::encrypt_chunk(dek_1, c_id, 2, &root_page_bytes, FORMAT_VERSION, 0)?; let inner_ct_len_2 = inner_ct_2.len() as u32; let mut outer_plaintext_2 = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut outer_plaintext_2); outer_plaintext_2[0..12].copy_from_slice(&inner_nonce_2); outer_plaintext_2[12..28].copy_from_slice(&inner_tag_2); outer_plaintext_2[28..32].copy_from_slice(&inner_ct_len_2.to_le_bytes()); let ct_end_2 = 32 + inner_ct_2.len(); if ct_end_2 > CHUNK_SIZE { bail!("Inode-Page-Payload zu groß für Block 2"); } outer_plaintext_2[32..ct_end_2].copy_from_slice(&inner_ct_2); let (outer_ct_2, outer_nonce_2, outer_tag_2) = crate::crypto::encrypt_chunk( dek_0, c_id, 2, &outer_plaintext_2, FORMAT_VERSION, 0, )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 2, 0, ?2, ?3, ?4)", params![ c_id, outer_nonce_2.as_slice(), outer_tag_2.as_slice(), outer_ct_2 ], )?; // 2. Superblock (Block 0 & 1, redundante Zweitkopie C-02, Format V2) let superblock = crate::carrier::CarrierSuperblock { magic: *crate::carrier::CARRIER_MAGIC, version: crate::carrier::CARRIER_VERSION_V2, manifest_generation: 0, total_blocks, free_blocks: manifest.free_blocks.clone(), next_inode_id: manifest.next_inode_id, page_block_indices: vec![2], }; let sb_bytes = serde_json::to_vec(&superblock)?; // Block 0 schreiben (Primärkopie) let (inner_ct_0, inner_nonce_0, inner_tag_0) = crate::crypto::encrypt_chunk(dek_1, c_id, 0, &sb_bytes, FORMAT_VERSION, 0)?; let inner_ct_len_0 = inner_ct_0.len() as u32; let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut outer_plaintext_0); outer_plaintext_0[0..12].copy_from_slice(&inner_nonce_0); outer_plaintext_0[12..28].copy_from_slice(&inner_tag_0); outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes()); let ct_end_0 = 32 + inner_ct_0.len(); if ct_end_0 > CHUNK_SIZE { bail!("Superblock-Payload zu groß für Block 0"); } outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0); let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk( dek_0, c_id, 0, &outer_plaintext_0, FORMAT_VERSION, 0, )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)", params![ c_id, outer_nonce_0.as_slice(), outer_tag_0.as_slice(), outer_ct_0 ], )?; // Block 1 schreiben (Redundante Zweitkopie, C-02) let (inner_ct_1, inner_nonce_1, inner_tag_1) = crate::crypto::encrypt_chunk(dek_1, c_id, 1, &sb_bytes, FORMAT_VERSION, 0)?; let inner_ct_len_1 = inner_ct_1.len() as u32; let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut outer_plaintext_1); outer_plaintext_1[0..12].copy_from_slice(&inner_nonce_1); outer_plaintext_1[12..28].copy_from_slice(&inner_tag_1); outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes()); let ct_end_1 = 32 + inner_ct_1.len(); if ct_end_1 > CHUNK_SIZE { bail!("Superblock-Payload zu groß für Block 1"); } outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1); let (outer_ct_1, outer_nonce_1, outer_tag_1) = crate::crypto::encrypt_chunk( dek_0, c_id, 1, &outer_plaintext_1, FORMAT_VERSION, 0, )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 1, 0, ?2, ?3, ?4)", params![ c_id, outer_nonce_1.as_slice(), outer_tag_1.as_slice(), outer_ct_1 ], )?; // 3. Blöcke 3..total_blocks-1 mit DEK_0 vorallokieren let mut chunk_stmt = conn.prepare( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, ?2, 0, ?3, ?4, ?5)", )?; let mut dummy_noise = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut dummy_noise); conn.execute_batch("BEGIN TRANSACTION;")?; for b in 3..total_blocks { let (ct, nonce, tag) = crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?; chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?; if b % 500 == 0 { conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?; } } conn.execute_batch("COMMIT;")?; drop(chunk_stmt); self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0)); drop(conn); let _ = self.update_metadata_mac(); self.set_active_slot_and_dek(1, Zeroizing::new(*dek_1)); let _ = self.update_metadata_mac(); self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0)); return Ok(Some(c_id)); } else { // Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A) let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let dummy_params_json = serde_json::to_string(&KdfParams::default())?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, dummy_salt.as_slice(), dummy_params_json, dummy_dek.as_slice(), dummy_nonce.as_slice(), dummy_tag.as_slice(), ], )?; None }; drop(conn); let _ = self.update_metadata_mac(); Ok(carrier_node_id) } /// Initialisiert das Datenbankschema mit Unterstützung für Dual-Vault (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], kdf_params: &KdfParams, wrapped_dek: &[u8], header_nonce: &[u8; 12], header_tag: &[u8; 16], hidden: Option<(&[u8; 16], &KdfParams, &[u8], &[u8; 12], &[u8; 16])>, ) -> Result<()> { let conn = self.conn(); conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta ( slot_id INTEGER NOT NULL PRIMARY KEY, 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, metadata_mac BLOB, metadata_gen INTEGER NOT NULL DEFAULT 0, lock_pid INTEGER, lock_host TEXT, lock_time INTEGER ); CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER, name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, modified_at INTEGER NOT NULL, is_carrier INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE ); 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, chunk_index INTEGER NOT NULL, generation INTEGER NOT NULL DEFAULT 0, nonce BLOB NOT NULL, tag BLOB NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY (node_id, chunk_index), FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE );", )?; // Slot 0 einfügen (Standard / Decoy Vault) let params_json_0 = serde_json::to_string(kdf_params)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, salt.as_slice(), params_json_0, wrapped_dek, header_nonce.as_slice(), header_tag.as_slice(), ], )?; // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur) let now = current_timestamp(); conn.execute( "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], )?; // Slot 1: Entweder echter Hidden Vault ODER CSPRNG-Rauschen (Dummy-Slot-Längenparität) if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden { let params_json_1 = serde_json::to_string(h_params)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, h_salt.as_slice(), params_json_1, h_wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], )?; } else { // Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let dummy_params_json = serde_json::to_string(&KdfParams::default())?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, dummy_salt.as_slice(), dummy_params_json, dummy_dek.as_slice(), dummy_nonce.as_slice(), dummy_tag.as_slice(), ], )?; } drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot-Längenparität). pub fn init_schema( &self, salt: &[u8; 16], kdf_params: &KdfParams, wrapped_dek: &[u8], header_nonce: &[u8; 12], header_tag: &[u8; 16], ) -> Result<()> { self.init_schema_with_hidden( salt, kdf_params, wrapped_dek, header_nonce, header_tag, None, ) } /// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen). /// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch. pub fn read_slots(&self) -> Result> { let conn = self.conn(); // SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS) let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?; if slot_count == 0 { bail!("Container-Header ist leer oder beschädigt"); } if slot_count > 2 { bail!( "Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)", slot_count ); } let mut stmt = conn.prepare( "SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag FROM meta ORDER BY slot_id ASC", )?; let mut rows = stmt.query([])?; let mut slots = Vec::new(); let mut seen_slot_ids = std::collections::HashSet::new(); while let Some(row) = rows.next()? { let slot_id: u32 = row.get(0)?; let version: u32 = row.get(1)?; let salt_vec: Vec = row.get(2)?; let params_str: String = row.get(3)?; let wrapped_dek: Vec = row.get(4)?; let nonce_vec: Vec = row.get(5)?; let tag_vec: Vec = row.get(6)?; // SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig if slot_id > 1 { bail!( "Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", slot_id ); } if !seen_slot_ids.insert(slot_id) { bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id); } if salt_vec.len() != 16 { bail!( "Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len() ); } let mut kdf_salt = [0u8; 16]; kdf_salt.copy_from_slice(&salt_vec); if nonce_vec.len() != 12 { bail!( "Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len() ); } let mut header_nonce = [0u8; 12]; header_nonce.copy_from_slice(&nonce_vec); if tag_vec.len() != 16 { bail!( "Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len() ); } let mut header_tag = [0u8; 16]; header_tag.copy_from_slice(&tag_vec); // Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz) if slot_id == 0 { if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 { bail!( "Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len() ); } } else if slot_id == 1 { if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 { bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()); } } let kdf_params: KdfParams = serde_json::from_str(¶ms_str).map_err(|e| { anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id) })?; validate_kdf_params(&kdf_params) .map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?; slots.push(SlotMeta { slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, }); } // SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich if !seen_slot_ids.contains(&0) { bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt"); } Ok(slots) } /// Liest die Metadaten des Containers (Slot 0 und alle Slots) aus der `meta`-Tabelle aus und verifiziert die Magic Bytes. pub fn read_meta(&self) -> Result { let slots = self.read_slots()?; if slots.is_empty() { bail!("Container-Header ist leer oder beschädigt"); } let slot0 = &slots[0]; let conn = self.conn(); let magic: Vec = conn.query_row( "SELECT magic FROM meta WHERE slot_id = 0 LIMIT 1", [], |r| r.get(0), )?; if magic.as_slice() != MAGIC_BYTES.as_slice() { bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein"); } if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 && slot0.version != FORMAT_VERSION_V3 { bail!( "Nicht unterstützte Sanctum-Formatversion: {}", slot0.version ); } Ok(ContainerMeta { 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, }) } /// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK in Slot 0 (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<()> { self.update_slot_keys( 0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag, ) } /// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots. pub fn update_slot_keys( &self, slot_id: u32, 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(); 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 WHERE slot_id = ?6", params![ new_salt.as_slice(), params_json, new_wrapped_dek, new_header_nonce.as_slice(), new_header_tag.as_slice(), slot_id, ], )?; if rows_affected == 0 { bail!( "Konnte Container-Header für Slot {} nicht aktualisieren", slot_id ); } Ok(()) } /// Aktualisiert die Version in der meta-Tabelle (z. B. für Migrationen oder Tests). pub fn set_meta_version(&self, version: u32) -> Result<()> { let conn = self.conn(); let rows_affected = conn.execute("UPDATE meta SET version = ?1", params![version])?; if rows_affected == 0 { bail!("Konnte Container-Version nicht aktualisieren: meta-Tabelle ist leer"); } Ok(()) } /// Ermittelt die Root-Knoten-ID für einen bestimmten Vault (Vault 0 = 1, Vault 1 = 2). pub fn get_root_node_id_for_vault(vault_id: u32) -> i64 { if vault_id == 1 { 2 } else { 1 } } /// 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(); 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, raw_path: &str, vault_id: u32, dek: &[u8; 32], ) -> Result> { let root_id = Self::get_root_node_id_for_vault(vault_id); let normalized = raw_path.trim_matches('/'); if normalized.is_empty() { return self.get_node_by_id_in_vault(root_id, vault_id, dek); } let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); let conn = self.conn(); let mut current_id = root_id; let mut last_record = None; for (idx, segment) in segments.iter().enumerate() { if vault_id == 0 { let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes WHERE parent_id = ?1 AND name = ?2", )?; let record: Option = stmt .query_row(params![current_id, segment], |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, }) }) .optional()?; match record { Some(rec) => { if idx + 1 < segments.len() && !rec.is_dir { return Ok(None); } current_id = rec.id; last_record = Some(rec); } None => return Ok(None), } } else { // Hidden Vault: Durchsuche Kinder des aktuellen Ordners und entschlüssele die Namen let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes WHERE parent_id = ?1", )?; let rows = stmt.query_map(params![current_id], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, row.get::<_, i32>(3)? != 0, row.get::<_, i64>(4)? as u64, row.get::<_, i64>(5)? as u64, row.get::<_, i64>(6)? as u64, )) })?; let mut matched_record = None; for r in rows { let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) { Some(name) => name, None => { warn!( "Knoten {} in Pfadauflösung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)", id ); continue; } }; if dec_name == *segment { matched_record = Some(NodeRecord { id, parent_id: p_id, name: dec_name, is_dir, size, created_at: c_at, modified_at: m_at, }); break; } } match matched_record { Some(rec) => { if idx + 1 < segments.len() && !rec.is_dir { return Ok(None); } current_id = rec.id; last_record = Some(rec); } None => return Ok(None), } } } Ok(last_record) } /// Löst einen hierarchischen Pfad im Standard-Vault (Vault 0) auf. pub fn resolve_path(&self, raw_path: &str) -> Result> { self.resolve_path_in_vault(raw_path, 0, &[0u8; 32]) } pub fn get_node_by_id_in_vault( &self, id: i64, vault_id: u32, dek: &[u8; 32], ) -> Result> { let conn = self.conn(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes WHERE id = ?1", )?; let raw = stmt .query_row(params![id], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, row.get::<_, i32>(3)? != 0, row.get::<_, i64>(4)? as u64, row.get::<_, i64>(5)? as u64, row.get::<_, i64>(6)? as u64, )) }) .optional()?; match raw { Some((id, parent_id, enc_name, is_dir, size, created_at, modified_at)) => { let name = if vault_id == 1 && parent_id.is_some() { match decrypt_node_name(dek, parent_id.unwrap_or(0), &enc_name) { Some(dec) => dec, None => { warn!( "Knoten {} kann nicht geladen werden: Name konnte nicht entschlüsselt werden (ST-01)", id ); return Ok(None); } } } else { enc_name }; Ok(Some(NodeRecord { id, parent_id, name, is_dir, size, created_at, modified_at, })) } None => Ok(None), } } pub fn get_node_by_id(&self, id: i64) -> Result> { self.get_node_by_id_in_vault(id, 0, &[0u8; 32]) } /// Listet alle direkten Kinder eines Verzeichnisknotens innerhalb eines Vaults auf. pub fn list_children_in_vault( &self, parent_id: i64, vault_id: u32, dek: &[u8; 32], ) -> Result> { let conn = self.conn(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes WHERE parent_id = ?1 ORDER BY is_dir DESC, id ASC", )?; let rows = stmt.query_map(params![parent_id], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, row.get::<_, i32>(3)? != 0, row.get::<_, i64>(4)? as u64, row.get::<_, i64>(5)? as u64, row.get::<_, i64>(6)? as u64, )) })?; let mut entries = Vec::new(); for r in rows { let (id, p_id, enc_name, is_dir, size, created_at, modified_at) = r?; let name = if vault_id == 1 { match decrypt_node_name(dek, parent_id, &enc_name) { Some(dec) => dec, None => { warn!( "Knoten {} in Verzeichnisauflistung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)", id ); continue; } } } else { enc_name }; entries.push(NodeRecord { id, parent_id: p_id, name, is_dir, size, created_at, modified_at, }); } Ok(entries) } /// Listet alle direkten Kinder eines Verzeichnisknotens im Standard-Vault auf. pub fn list_children(&self, parent_id: i64) -> Result> { self.list_children_in_vault(parent_id, 0, &[0u8; 32]) } /// Erstellt einen neuen Datei- oder Ordnerknoten in einem bestimmten Vault. pub fn create_node_in_vault( &self, vault_id: u32, parent_id: i64, name: &str, is_dir: bool, dek: &[u8; 32], ) -> Result { crate::pathutil::validate_node_name(name)?; let now = current_timestamp(); let conn = self.conn(); let stored_name = if vault_id == 1 { encrypt_node_name(dek, parent_id, name) } else { name.to_string() }; conn.execute( "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(); let _ = conn.execute( "UPDATE nodes SET modified_at = ?1 WHERE id = ?2", params![now, parent_id], ); drop(conn); let _ = self.update_metadata_mac(); Ok(NodeRecord { id: new_id, parent_id: Some(parent_id), name: name.to_string(), is_dir, size: 0, created_at: now, modified_at: now, }) } /// Erstellt einen neuen Datei- oder Ordnerknoten im Standard-Vault. pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { self.create_node_in_vault(0, parent_id, name, is_dir, &[0u8; 32]) } /// Aktualisiert Dateigröße und Modifikationszeitstempel eines Knotens. pub fn update_node_size_and_time(&self, id: i64, size: u64, modified_at: u64) -> Result<()> { let conn = self.conn(); conn.execute( "UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3", params![size as i64, modified_at as i64, id], )?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen /// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig /// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03). pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> { if let Ok(Some(carrier_id)) = self.find_carrier_node_id() { if node_id == carrier_id { bail!( "Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).", node_id ); } } Ok(()) } fn delete_subtree_tx( tx: &rusqlite::Transaction<'_>, id: i64, carrier_id: Option, ) -> Result<()> { if let Some(cid) = carrier_id { if id == cid { bail!( "Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.", id ); } } // 1. Chunks für diesen Knoten kryptografisch shreddern { let mut stmt = tx .prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; let chunks: Vec<(u32, usize)> = stmt .query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))? .filter_map(|r| r.ok()) .collect(); let mut update_stmt = tx.prepare( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", )?; for (chunk_idx, ct_len) in chunks { let mut noise = vec![0u8; ct_len]; let mut nonce_noise = [0u8; 12]; let mut tag_noise = [0u8; 16]; OsRng.fill_bytes(&mut noise); OsRng.fill_bytes(&mut nonce_noise); OsRng.fill_bytes(&mut tag_noise); let _ = update_stmt.execute(params![ nonce_noise.as_slice(), tag_noise.as_slice(), noise, id, chunk_idx ]); } } // 2. Kindknoten ermitteln und rekursiv innerhalb derselben Transaktion löschen let child_ids: Vec = { let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?; let ids = stmt .query_map(params![id], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); ids }; let mut errors = Vec::new(); for child_id in child_ids { if let Err(e) = Self::delete_subtree_tx(tx, child_id, carrier_id) { error!("Konnte Kindknoten {} nicht löschen: {e}", child_id); errors.push(e); } } if !errors.is_empty() { bail!( "Fehler beim rekursiven Löschen: {} Kindknoten konnten nicht gelöscht werden (ST-02)", errors.len() ); } // 3. Chunks und Knoten aus der Datenbank löschen tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?; tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?; Ok(()) } /// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar in einer SQLite-Transaktion (ST-03). pub fn delete_node(&self, id: i64) -> Result<()> { self.assert_not_carrier(id)?; // Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält let carrier_id = self.find_carrier_node_id().ok().flatten(); if let Some(cid) = carrier_id { if self.is_descendant_of(cid, id).unwrap_or(false) { bail!( "Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", cid ); } } let mut conn = self.conn(); let tx = conn.transaction()?; Self::delete_subtree_tx(&tx, id, carrier_id)?; tx.commit()?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Benennt einen Knoten um und/oder verschiebt ihn in ein anderes Verzeichnis. pub fn rename_node_in_vault( &self, id: i64, new_parent_id: i64, new_name: &str, vault_id: u32, dek: &[u8; 32], ) -> Result<()> { self.assert_not_carrier(id)?; crate::pathutil::validate_node_name(new_name)?; let now = current_timestamp(); let conn = self.conn(); let stored_name = if vault_id == 1 { encrypt_node_name(dek, new_parent_id, new_name) } else { new_name.to_string() }; conn.execute( "UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4", params![new_parent_id, stored_name, now, id], )?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> { self.rename_node_in_vault(id, new_parent_id, new_name, 0, &[0u8; 32]) } /// Liest einen verschlüsselten Chunk aus der Datenbank. pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result> { let conn = self.conn(); let mut stmt = conn.prepare( "SELECT nonce, tag, ciphertext, generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2", )?; let record = stmt .query_row(params![node_id, chunk_index], |row| { let nonce_vec: Vec = row.get(0)?; let tag_vec: Vec = row.get(1)?; let ciphertext: Vec = row.get(2)?; let generation: i64 = row.get(3).unwrap_or(0); 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(ChunkRecord { node_id, chunk_index, generation: generation as u64, nonce, tag, ciphertext, }) }) .optional()?; Ok(record) } /// Ermittelt die nächste Generation für einen Chunk (K-02 Chunk-Replay-Schutz). /// Garantiert eine strikt monoton steigende Generation containerweit. pub fn next_chunk_generation(&self, node_id: i64, chunk_index: u32) -> Result { let conn = self.conn(); let current_gen: Option = conn .query_row( "SELECT generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2", params![node_id, chunk_index], |r| r.get(0), ) .optional()?; let max_gen: i64 = conn .query_row("SELECT COALESCE(MAX(generation), 0) FROM chunks", [], |r| { r.get(0) }) .unwrap_or(0); let next = match current_gen { Some(g) => (g + 1).max(max_gen + 1), None => max_gen + 1, }; Ok(next as u64) } /// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank. pub fn write_chunk( &self, node_id: i64, chunk_index: u32, generation: u64, nonce: &[u8; 12], tag: &[u8; 16], ciphertext: &[u8], ) -> Result<()> { let conn = self.conn(); conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(node_id, chunk_index) DO UPDATE SET generation = excluded.generation, nonce = excluded.nonce, tag = excluded.tag, ciphertext = excluded.ciphertext", params![ node_id, chunk_index, generation as i64, nonce.as_slice(), tag.as_slice(), ciphertext, ], )?; Ok(()) } /// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar /// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01). pub fn write_chunk_and_update_size( &self, node_id: i64, chunk_index: u32, generation: u64, nonce: &[u8; 12], tag: &[u8; 16], ciphertext: &[u8], new_size: u64, modified_at: u64, ) -> Result<()> { self.assert_not_carrier(node_id)?; let mut conn = self.conn(); let tx = conn.transaction()?; tx.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(node_id, chunk_index) DO UPDATE SET generation = excluded.generation, nonce = excluded.nonce, tag = excluded.tag, ciphertext = excluded.ciphertext", params![ node_id, chunk_index, generation as i64, nonce.as_slice(), tag.as_slice(), ciphertext, ], )?; tx.execute( "UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3", params![new_size as i64, modified_at as i64, node_id], )?; tx.commit()?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei) /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { self.assert_not_carrier(node_id)?; let mut conn = self.conn(); let tx = conn.transaction()?; { let mut stmt = tx.prepare( "SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1 AND chunk_index > ?2", )?; let chunks_to_shred: Vec<(u32, usize)> = stmt .query_map(params![node_id, max_chunk_index], |row| { let idx: u32 = row.get(0)?; let len: usize = row.get::<_, Option>(1)?.unwrap_or(0); Ok((idx, len)) })? .collect::, rusqlite::Error>>()?; let mut update_stmt = tx.prepare( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", )?; for (idx, len) in chunks_to_shred { let mut dummy_nonce = [0u8; 12]; let mut dummy_tag = [0u8; 16]; let mut dummy_payload = vec![0u8; len]; OsRng.fill_bytes(&mut dummy_nonce); OsRng.fill_bytes(&mut dummy_tag); OsRng.fill_bytes(&mut dummy_payload); update_stmt.execute(params![ dummy_nonce.as_slice(), dummy_tag.as_slice(), dummy_payload.as_slice(), node_id, idx ])?; } } tx.execute( "DELETE FROM chunks WHERE node_id = ?1 AND chunk_index > ?2", params![node_id, max_chunk_index], )?; tx.commit()?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Löscht und shreddert alle Chunks eines Knotens (z. B. beim Kürzen auf 0 Bytes) (S-05). pub fn delete_all_chunks(&self, node_id: i64) -> Result<()> { self.assert_not_carrier(node_id)?; let mut conn = self.conn(); let tx = conn.transaction()?; { let mut stmt = tx .prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; let chunks_to_shred: Vec<(u32, usize)> = stmt .query_map(params![node_id], |row| { Ok((row.get(0)?, row.get::<_, usize>(1)?)) })? .filter_map(|r| r.ok()) .collect(); let mut update_stmt = tx.prepare( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", )?; for (idx, len) in chunks_to_shred { let mut dummy_nonce = [0u8; 12]; let mut dummy_tag = [0u8; 16]; let mut dummy_payload = vec![0u8; len]; OsRng.fill_bytes(&mut dummy_nonce); OsRng.fill_bytes(&mut dummy_tag); OsRng.fill_bytes(&mut dummy_payload); let _ = update_stmt.execute(params![ dummy_nonce.as_slice(), dummy_tag.as_slice(), dummy_payload.as_slice(), node_id, idx ]); } } tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![node_id])?; tx.commit()?; drop(conn); let _ = self.update_metadata_mac(); Ok(()) } /// Prüft den aktuellen Advisory-Lock-Status (S-06). /// Gibt `Some((pid, host, timestamp))` zurück, falls ein Lock aktiv und der Prozess noch am Leben ist. pub fn check_advisory_lock(&self) -> Result> { let conn = self.conn(); let mut stmt = conn .prepare("SELECT lock_pid, lock_host, lock_time FROM meta WHERE slot_id = 0 LIMIT 1")?; let lock_info = stmt .query_row([], |row| { let pid: Option = row.get(0)?; let host: Option = row.get(1)?; let time: Option = row.get(2)?; Ok((pid, host, time)) }) .optional()?; if let Some((Some(pid), Some(host), Some(time))) = lock_info { let current_host = std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_else(|_| "localhost".to_string()); // Falls gleicher Host, prüfe ob PID noch läuft if host == current_host { if crate::platform::is_process_alive(pid as u32) { return Ok(Some((pid as u32, host, time as u64))); } else { // Verwaister Lock von abgestürztem Prozess -> ignorieren/löschen drop(stmt); drop(conn); let _ = self.release_advisory_lock(); return Ok(None); } } else { return Ok(Some((pid as u32, host, time as u64))); } } Ok(None) } /// Setzt einen Advisory Lock auf den Container (S-06). pub fn acquire_advisory_lock(&self, force: bool) -> Result<()> { if !force { if let Some((pid, host, time)) = self.check_advisory_lock()? { bail!( "Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.", pid, host, time ); } } let pid = std::process::id(); let host = std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_else(|_| "localhost".to_string()); let now = current_timestamp(); let conn = self.conn(); conn.execute( "UPDATE meta SET lock_pid = ?1, lock_host = ?2, lock_time = ?3 WHERE slot_id = 0", params![pid as i64, host, now], )?; Ok(()) } /// Entfernt den Advisory Lock (S-06). pub fn release_advisory_lock(&self) -> Result<()> { let conn = self.conn(); conn.execute( "UPDATE meta SET lock_pid = NULL, lock_host = NULL, lock_time = NULL WHERE slot_id = 0", [], )?; Ok(()) } /// Setzt einen Advisory Lock und gibt einen RAII Guard zurück (S-06). pub fn acquire_advisory_lock_guard(&self, force: bool) -> Result { self.acquire_advisory_lock(force)?; Ok(AdvisoryLockGuard { db: self.clone() }) } /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). pub fn canonical_nodes_bytes(&self) -> Result> { self.canonical_nodes_bytes_for_vault(0) } /// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault. pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result> { let conn = self.conn(); let mut buf = Vec::new(); if vault_id == 1 { let mut stmt = conn.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 n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier, (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count FROM nodes n WHERE n.id IN (SELECT id FROM vault1) ORDER BY n.id ASC", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { Self::serialize_node_row(&row, &mut buf)?; } } else { let mut stmt = conn.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 n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier, (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count FROM nodes n WHERE n.id = 2 OR n.id IN (SELECT id FROM vault0) ORDER BY n.id ASC", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { Self::serialize_node_row(&row, &mut buf)?; } } Ok(buf) } fn serialize_node_row(row: &rusqlite::Row, buf: &mut Vec) -> Result<()> { let id: i64 = row.get(0)?; let parent_id: Option = row.get(1)?; let name: String = row.get(2)?; let is_dir: i64 = row.get(3)?; let size: i64 = row.get(4)?; let created_at: i64 = row.get(5)?; let modified_at: i64 = row.get(6)?; let is_carrier: i64 = row.get(7)?; let chunk_count: i64 = row.get(8)?; buf.extend_from_slice(&id.to_le_bytes()); match parent_id { Some(pid) => { buf.push(1u8); buf.extend_from_slice(&pid.to_le_bytes()); } None => { buf.push(0u8); buf.extend_from_slice(&0i64.to_le_bytes()); } } let name_bytes = name.as_bytes(); buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); buf.extend_from_slice(name_bytes); buf.push(if is_dir != 0 { 1u8 } else { 0u8 }); buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&created_at.to_le_bytes()); buf.extend_from_slice(&modified_at.to_le_bytes()); buf.push(if is_carrier != 0 { 1u8 } else { 0u8 }); buf.extend_from_slice(&chunk_count.to_le_bytes()); Ok(()) } /// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01). pub fn update_metadata_mac(&self) -> Result<()> { let session_opt = self .active_session .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); let Some((slot_id, dek)) = session_opt else { return Ok(()); }; let conn = self.conn(); let version_and_gen: Option<(u32, u64)> = conn .query_row( "SELECT version, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1", params![slot_id], |r| Ok((r.get(0)?, r.get(1).unwrap_or(0))), ) .optional()?; let Some((version, current_gen)) = version_and_gen else { return Ok(()); }; if version < FORMAT_VERSION_V3 { return Ok(()); } drop(conn); let next_gen = current_gen + 1; let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let mac_key = derive_metadata_mac_key(&dek); let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical); let conn = self.conn(); conn.execute( "UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3", params![new_mac.as_slice(), next_gen, slot_id], )?; Ok(()) } /// Prüft die Integrität des Metadaten-MAC gegen den gegebenen DEK (Format V3 / K-01). pub fn verify_metadata_mac(&self, dek: &[u8; 32]) -> Result { if self.verify_metadata_mac_for_slot(0, dek)? { return Ok(true); } if self.verify_metadata_mac_for_slot(1, dek)? { return Ok(true); } Ok(false) } /// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden). pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result { Ok(self.verify_metadata_mac_status_for_slot(slot_id, dek)? == MetadataMacStatus::Valid) } /// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1). pub fn verify_metadata_mac_status_for_slot( &self, slot_id: u32, dek: &[u8; 32], ) -> Result { let conn = self.conn(); let meta_row: Option<(u32, Option>, u64)> = conn .query_row( "SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1", params![slot_id], |r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))), ) .optional()?; let Some((version, mac_opt, gen)) = meta_row else { return Ok(MetadataMacStatus::Invalid); }; if version < FORMAT_VERSION_V3 { return Ok(MetadataMacStatus::Valid); } // R-NEW-1: Frisch restaurierter Container (gen == 0 && mac_opt IS NULL) if gen == 0 && mac_opt.is_none() { return Ok(MetadataMacStatus::PendingRebuild); } let Some(mac_bytes) = mac_opt else { return Ok(MetadataMacStatus::Invalid); }; if mac_bytes.len() != 32 { return Ok(MetadataMacStatus::Invalid); } let mut expected_mac = [0u8; 32]; expected_mac.copy_from_slice(&mac_bytes); drop(conn); let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let mac_key = derive_metadata_mac_key(dek); if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) { Ok(MetadataMacStatus::Valid) } else { Ok(MetadataMacStatus::Invalid) } } /// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02). pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> { let conn = self.conn(); let version: u32 = conn.query_row( "SELECT version FROM meta WHERE slot_id = 0 LIMIT 1", [], |r| r.get(0), )?; if version >= FORMAT_VERSION_V3 { return Ok(()); } let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []); let _ = conn.execute( "ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0", [], ); let _ = conn.execute( "ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0", [], ); // 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 chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec)> = chunk_stmt .query_map([], |row| { let node_id: i64 = row.get(0)?; let chunk_index: u32 = row.get(1)?; 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((node_id, chunk_index, nonce, tag, ciphertext)) })? .collect::, _>>()?; drop(chunk_stmt); for (node_id, chunk_index, nonce, tag, ct) in chunk_rows { if let Ok(plaintext) = crate::crypto::decrypt_chunk( dek, node_id, chunk_index, &ct, &nonce, &tag, version, 0, ) { if let Ok((new_ct, new_nonce, new_tag)) = crate::crypto::encrypt_chunk( dek, node_id, chunk_index, &plaintext, FORMAT_VERSION_V3, 0, ) { conn.execute( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", params![ new_nonce.as_slice(), new_tag.as_slice(), new_ct, node_id, chunk_index ], )?; } } } } conn.execute( "UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0", [FORMAT_VERSION_V3], )?; drop(conn); self.set_active_dek(Zeroizing::new(*dek)); self.update_metadata_mac()?; Ok(()) } /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. pub fn checkpoint(&self) -> Result<()> { let conn = self.conn(); let _res: (i64, i64, i64) = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) })?; let _ = conn.execute_batch("PRAGMA incremental_vacuum;"); Ok(()) } /// Erstellt ein konsistentes Online-Live-Backup der gesamten Container-Datenbank via SQLite Online Backup API. /// Kann auch während eines aktiven WebDAV-Mounts ohne Lese-/Schreibkonflikte ausgeführt werden. pub fn online_backup>(&self, dest_path: P) -> Result<()> { let dest_path = dest_path.as_ref(); if let Some(parent) = dest_path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } let mut dest_conn = Connection::open(dest_path)?; let src_conn = self.conn(); let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?; backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?; drop(backup); dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; Ok(()) } /// Stellt einen Container vollständig aus einer Sicherungskopie wieder her und verifiziert die Konsistenz. pub fn restore_from_backup>(backup_path: P, dest_path: P) -> Result<()> { let backup_path = backup_path.as_ref(); let dest_path = dest_path.as_ref(); if !backup_path.exists() { bail!("Backup-Datei '{}' existiert nicht.", backup_path.display()); } if let Some(parent) = dest_path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } let src_conn = Connection::open(backup_path)?; let mut dest_conn = Connection::open(dest_path)?; let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?; backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?; drop(backup); drop(src_conn); dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; // B-Tree Integritätsprüfung let check: String = dest_conn.query_row("PRAGMA quick_check;", [], |r| r.get(0))?; if check != "ok" { bail!("Integritätsprüfung des wiederhergestellten Containers fehlgeschlagen: {check}"); } 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(); conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta ( slot_id INTEGER PRIMARY KEY DEFAULT 0, 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, metadata_mac BLOB, metadata_gen INTEGER NOT NULL DEFAULT 0, lock_pid INTEGER, lock_host TEXT, lock_time INTEGER );", )?; 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, metadata_mac, metadata_gen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0)", params![ slot.slot_id, MAGIC_BYTES.as_slice(), slot.version, slot.kdf_salt.as_slice(), params_json, slot.wrapped_dek, slot.header_nonce.as_slice(), slot.header_tag.as_slice(), ], )?; } } else { let params_json = serde_json::to_string(&meta.kdf_params)?; conn.execute( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen) VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)", 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(), ], )?; } // Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Längenparität 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( "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)", params![ MAGIC_BYTES.as_slice(), FORMAT_VERSION, dummy_salt.as_slice(), dummy_params_json, dummy_dek.as_slice(), dummy_nonce.as_slice(), dummy_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(); 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(); 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(); 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(); 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)] mod tests { use super::*; #[test] fn test_storage_schema_and_path_resolution() { let db = Database::open_in_memory().unwrap(); let salt = [1u8; 16]; let kdf_params = KdfParams::default(); let wrapped_dek = vec![2u8; 32]; let nonce = [3u8; 12]; let tag = [4u8; 16]; db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); // Meta abrufen let meta = db.read_meta().unwrap(); assert_eq!(meta.version, FORMAT_VERSION); assert_eq!(meta.kdf_salt, salt); assert_eq!(meta.wrapped_dek, wrapped_dek); // Root prüfen let root = db.resolve_path("/").unwrap().expect("Root node must exist"); assert_eq!(root.id, 1); assert!(root.is_dir); // Ordner und Datei erstellen let docs = db.create_node(root.id, "documents", true).unwrap(); assert_eq!(docs.name, "documents"); assert!(docs.is_dir); let file = db.create_node(docs.id, "notes.txt", false).unwrap(); assert_eq!(file.name, "notes.txt"); assert!(!file.is_dir); // Pfadauflösung testen let resolved_file = db .resolve_path("/documents/notes.txt") .unwrap() .expect("File should resolve"); assert_eq!(resolved_file.id, file.id); let resolved_docs = db .resolve_path("documents") .unwrap() .expect("Docs should resolve"); assert_eq!(resolved_docs.id, docs.id); // Chunks schreiben & lesen let test_cipher = b"ENCRYPTED_DATA_BLOCK"; let c_nonce = [7u8; 12]; let c_tag = [8u8; 16]; db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, test_cipher) .unwrap(); let chunk = db .read_chunk(file.id, 0) .unwrap() .expect("Chunk 0 should exist"); assert_eq!(chunk.ciphertext, test_cipher); // Truncate db.truncate_chunks_after(file.id, 0).unwrap(); let chunk_after = db.read_chunk(file.id, 0).unwrap(); assert!(chunk_after.is_some()); // Löschen db.delete_node(file.id).unwrap(); let deleted_res = db.resolve_path("/documents/notes.txt").unwrap(); assert!(deleted_res.is_none()); assert!(db.read_chunk(file.id, 0).unwrap().is_none()); } #[test] fn test_storage_compaction_and_incremental_vacuum() { let temp_dir = std::env::temp_dir(); let db_path = temp_dir.join(format!("compact_test_{}.sanctum", std::process::id())); if db_path.exists() { let _ = std::fs::remove_file(&db_path); } let db = Database::open(&db_path).unwrap(); let salt = [1u8; 16]; let kdf_params = KdfParams::default(); let wrapped_dek = vec![2u8; 32]; let nonce = [3u8; 12]; let tag = [4u8; 16]; db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); let root = db.resolve_path("/").unwrap().expect("Root node"); let file = db.create_node(root.id, "large_file.bin", false).unwrap(); // 20 Chunks à 64 KB schreiben, um SQLite Seiten zuzuweisen let payload = vec![0x42u8; 64 * 1024]; let c_nonce = [5u8; 12]; let c_tag = [6u8; 16]; for i in 0..20 { db.write_chunk(file.id, i, 0, &c_nonce, &c_tag, &payload) .unwrap(); } db.checkpoint().unwrap(); // Datei löschen -> Chunks werden geschreddert und Seiten wandern in Freelist db.delete_node(file.id).unwrap(); db.checkpoint().unwrap(); let freelist_before = db.freelist_count().unwrap(); assert!( freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten" ); // Incremental Vacuum ausführen let freed = db.incremental_vacuum(None).unwrap(); assert!(freed > 0, "Es sollten Seiten freigegeben werden"); assert_eq!( freed, freelist_before, "Alle freien Seiten müssen freigegeben werden" ); let freelist_after = db.freelist_count().unwrap(); assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein"); let _ = std::fs::remove_file(&db_path); } #[test] fn test_cryptographic_chunk_shredding() { let db = Database::open_in_memory().unwrap(); let salt = [1u8; 16]; let kdf_params = KdfParams::default(); let wrapped_dek = vec![2u8; 32]; let nonce = [3u8; 12]; let tag = [4u8; 16]; db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); let root = db.resolve_path("/").unwrap().expect("Root"); let file = db.create_node(root.id, "sensitive.dat", false).unwrap(); let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT"; let c_nonce = [10u8; 12]; let c_tag = [11u8; 16]; db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, sensitive_payload) .unwrap(); // Shredde Chunks db.shred_chunks_for_node(file.id).unwrap(); // Prüfe, was sich in der Chunks-Tabelle befindet let chunk = db .read_chunk(file.id, 0) .unwrap() .expect("Chunk existiert noch"); assert_ne!( chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!" ); assert_eq!( chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein" ); assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein"); assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein"); } #[test] fn test_v09_truncate_chunks_after_shreds_before_deletion() { let db = Database::open_in_memory().unwrap(); let salt = [1u8; 16]; let kdf_params = KdfParams::default(); let wrapped_dek = vec![2u8; 32]; let nonce = [3u8; 12]; let tag = [4u8; 16]; db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); let root = db.resolve_path("/").unwrap().expect("Root"); let file = db.create_node(root.id, "truncate_test.dat", false).unwrap(); let payload_0 = b"CHUNK_ZERO_MUST_SURVIVE"; let payload_1 = b"CHUNK_ONE_MUST_BE_SHREDDED_AND_DELETED"; let payload_2 = b"CHUNK_TWO_MUST_BE_SHREDDED_AND_DELETED"; db.write_chunk(file.id, 0, 0, &[1u8; 12], &[2u8; 16], payload_0) .unwrap(); db.write_chunk(file.id, 1, 0, &[3u8; 12], &[4u8; 16], payload_1) .unwrap(); db.write_chunk(file.id, 2, 0, &[5u8; 12], &[6u8; 16], payload_2) .unwrap(); // Kürze alle Chunks nach Index 0 (also Chunks 1 und 2) db.truncate_chunks_after(file.id, 0).unwrap(); // Chunk 0 muss unberührt geblieben sein let c0 = db .read_chunk(file.id, 0) .unwrap() .expect("Chunk 0 survives"); assert_eq!(c0.ciphertext, payload_0); // Chunks 1 und 2 müssen gelöscht sein assert!(db.read_chunk(file.id, 1).unwrap().is_none()); assert!(db.read_chunk(file.id, 2).unwrap().is_none()); } #[test] fn test_hidden_vault_isolation_and_filename_encryption() { let db = Database::open_in_memory().unwrap(); let salt0 = [1u8; 16]; let kdf_params0 = KdfParams::default(); let wrapped_dek0 = vec![10u8; 32]; let nonce0 = [11u8; 12]; let tag0 = [12u8; 16]; let salt1 = [2u8; 16]; let kdf_params1 = KdfParams::default(); let wrapped_dek1 = vec![20u8; 32]; let nonce1 = [21u8; 12]; let tag1 = [22u8; 16]; let dek0 = [0xAAu8; 32]; let dek1 = [0xBBu8; 32]; db.init_schema_with_hidden( &salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0, Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), ) .unwrap(); // Slots prüfen let slots = db.read_slots().unwrap(); assert_eq!(slots.len(), 2); assert_eq!(slots[0].slot_id, 0); assert_eq!(slots[1].slot_id, 1); // Datei in Vault 0 (Decoy) erstellen let root0 = db .resolve_path_in_vault("/", 0, &dek0) .unwrap() .expect("Root 0"); assert_eq!(root0.id, 1); let decoy_file = db .create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0) .unwrap(); // Datei in Vault 1 (Hidden) erstellen let root1 = db .resolve_path_in_vault("/", 1, &dek1) .unwrap() .expect("Root 1"); assert_eq!(root1.id, 2); let hidden_file = db .create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1) .unwrap(); // Auflösen in Vault 0: Sieht nur public_recipe.txt let res_decoy = db .resolve_path_in_vault("/public_recipe.txt", 0, &dek0) .unwrap(); assert!(res_decoy.is_some()); assert_eq!(res_decoy.unwrap().id, decoy_file.id); let res_hidden_in_v0 = db .resolve_path_in_vault("/classified_leak.pdf", 0, &dek0) .unwrap(); assert!( res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!" ); // Auflösen in Vault 1: Sieht nur classified_leak.pdf let res_hidden = db .resolve_path_in_vault("/classified_leak.pdf", 1, &dek1) .unwrap(); assert!(res_hidden.is_some()); assert_eq!(res_hidden.unwrap().id, hidden_file.id); 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-Tabellen let conn = db.conn(); let raw_name_v0: String = conn .query_row( "SELECT name FROM nodes WHERE id = ?1", params![decoy_file.id], |r| r.get(0), ) .unwrap(); assert_eq!(raw_name_v0, "public_recipe.txt"); let raw_name_v1: String = conn .query_row( "SELECT name FROM nodes WHERE id = ?1", params![hidden_file.id], |r| r.get(0), ) .unwrap(); // Dual-Vault Isolation: 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" ); } #[test] fn test_atomic_chunk_write_and_size_update() { let db = Database::open_in_memory().unwrap(); let (salt, kdf, wrapped_dek, nonce, tag) = ( [1u8; 16], KdfParams::default(), vec![2u8; 40], [3u8; 12], [4u8; 16], ); db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) .unwrap(); let file = db.create_node(1, "crash_test.bin", false).unwrap(); assert_eq!(file.size, 0); let chunk_nonce = [5u8; 12]; let chunk_tag = [6u8; 16]; let ciphertext = vec![7u8; 1024]; let new_size = 1024u64; let modified_at = 2000000u64; db.write_chunk_and_update_size( file.id, 0, 0, &chunk_nonce, &chunk_tag, &ciphertext, new_size, modified_at, ) .expect("Atomic write"); // Chunk verifizieren let chunk = db .read_chunk(file.id, 0) .unwrap() .expect("Chunk must exist"); assert_eq!(chunk.ciphertext, ciphertext); assert_eq!(chunk.nonce, chunk_nonce); assert_eq!(chunk.tag, chunk_tag); // Inode verifizieren let nodes = db.list_children(1).unwrap(); let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap(); assert_eq!(updated_file.size, new_size); assert_eq!(updated_file.modified_at, modified_at); } #[test] fn test_z02_poisoned_sqlite_mutex_recovery() { let db = Database::open_in_memory().unwrap(); let (salt, kdf, wrapped_dek, nonce, tag) = ( [1u8; 16], KdfParams::default(), vec![2u8; 40], [3u8; 12], [4u8; 16], ); db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) .unwrap(); // 1. Verifiziere normale Funktion let node1 = db.create_node(1, "normal.txt", false).unwrap(); assert_eq!(node1.name, "normal.txt"); // 2. Simuliere Thread-Panic während gehaltener Mutex-Sperre auf self.conn let db_clone = db.clone(); let handle = std::thread::spawn(move || { let _guard = db_clone.conn(); panic!("Simulierter Crash im Worker-Thread während aktiver SQLite-Verbindung"); }); let res = handle.join(); assert!( res.is_err(), "Worker-Thread muss wie erwartet gepanict haben" ); // 3. Mutex ist nun poisoned. Ohne Z-02 schlägt jeder nachfolgende Aufruf fehl. // Mit Z-02 fängt unwrap_or_else(|e| e.into_inner()) das Poisoning ab: let node2 = db.create_node(1, "after_poison.txt", false); assert!( node2.is_ok(), "Z-02: Datenbankoperationen müssen trotz vergiftetem Mutex erfolgreich wiederhergestellt werden" ); assert_eq!(node2.unwrap().name, "after_poison.txt"); // 4. Prüfe auch active_session Poisoning let db_clone2 = db.clone(); let handle2 = std::thread::spawn(move || { let _guard = db_clone2.active_session.lock().unwrap(); panic!("Simulierter Crash während active_session Sperre"); }); let _ = handle2.join(); // Setzen und Lesen muss weiterhin fehlerfrei funktionieren let test_dek = Zeroizing::new([42u8; 32]); db.set_active_dek(test_dek); let read_dek = db.active_dek(); assert!(read_dek.is_some()); assert_eq!(read_dek.unwrap()[0], 42); } #[test] fn test_st01_fail_closed_name_decryption_no_hex_leak() { let db = Database::open_in_memory().unwrap(); let (salt0, kdf0) = ([1u8; 16], KdfParams::default()); let (salt1, kdf1) = ([2u8; 16], KdfParams::default()); let (dek0, dek1) = ([10u8; 32], [20u8; 32]); let carrier_name = "backup.iso"; let carrier_size = 5 * 1024 * 1024; db.init_schema_with_carrier( &salt0, &kdf0, &[0u8; 40], &[0u8; 12], &[0u8; 16], Some(( carrier_name, carrier_size, &salt1, &kdf1, &[1u8; 40], &[1u8; 12], &[1u8; 16], &dek0, &dek1, )), ) .unwrap(); let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().unwrap(); assert_eq!(root1.id, 2); // 1. Zwei Dateien in Vault 1 anlegen let node_valid = db .create_node_in_vault(1, root1.id, "secret_valid.txt", false, &dek1) .unwrap(); let node_corrupt = db .create_node_in_vault(1, root1.id, "secret_corrupt.txt", false, &dek1) .unwrap(); // 2. Vor Korruption: Beide Dateien sichtbar let children_before = db.list_children_in_vault(root1.id, 1, &dek1).unwrap(); assert_eq!(children_before.len(), 2); // 3. Simuliere Bitrot / Manipulation: Überschreibe den verschlüsselten Namen in der DB { let conn = db.conn(); conn.execute( "UPDATE nodes SET name = 'deadbeef_invalid_ciphertext_without_valid_tag' WHERE id = ?1", params![node_corrupt.id], ) .unwrap(); } // 4. ST-01 Prüfungen (Fail-Closed): // a) list_children_in_vault filtert den korrupten Knoten heraus let children_after = db.list_children_in_vault(root1.id, 1, &dek1).unwrap(); assert_eq!( children_after.len(), 1, "ST-01: Korrupter Knoten muss aus der Liste herausgefiltert werden" ); assert_eq!(children_after[0].id, node_valid.id); assert_eq!(children_after[0].name, "secret_valid.txt"); assert!( !children_after.iter().any(|c| c.name.contains("deadbeef")), "Kein Hex-Ciphertext-Leak" ); // b) resolve_path_in_vault gibt None zurück (nicht den Hex-Namen) let resolved = db .resolve_path_in_vault("/secret_corrupt.txt", 1, &dek1) .unwrap(); assert!( resolved.is_none(), "ST-01: Pfad mit korruptem Namen darf nicht aufgelöst werden" ); // c) get_node_by_id_in_vault gibt None zurück (fail-closed statt Hex-String) let by_id = db .get_node_by_id_in_vault(node_corrupt.id, 1, &dek1) .unwrap(); assert!( by_id.is_none(), "ST-01: get_node_by_id_in_vault muss None zurückgeben, wenn Name unlesbar ist" ); // d) Gültiger Knoten kann normal geladen werden let valid_by_id = db.get_node_by_id_in_vault(node_valid.id, 1, &dek1).unwrap(); assert!(valid_by_id.is_some()); assert_eq!(valid_by_id.unwrap().name, "secret_valid.txt"); } #[test] fn test_st02_recursive_delete_error_propagation_not_swallowed() { let db = Database::open_in_memory().unwrap(); let (salt, kdf, wrapped_dek, nonce, tag) = ( [1u8; 16], KdfParams::default(), vec![2u8; 40], [3u8; 12], [4u8; 16], ); db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) .unwrap(); // Verzeichnisstruktur erstellen: // /parent (id=3) // /parent/normal.txt (id=4) // /parent/protected.txt (id=5) let parent = db.create_node(1, "parent", true).unwrap(); let _child_normal = db.create_node(parent.id, "normal.txt", false).unwrap(); let _child_protected = db.create_node(parent.id, "protected.txt", false).unwrap(); // SQLite-Trigger einrichten, der das Löschen von 'protected.txt' blockiert { let conn = db.conn(); conn.execute( "CREATE TRIGGER block_delete_protected BEFORE DELETE ON nodes WHEN OLD.name = 'protected.txt' BEGIN SELECT RAISE(FAIL, 'Löschen von protected.txt blockiert'); END;", [], ) .unwrap(); } // ST-02: delete_node(parent.id) darf den Fehler nicht mehr mit `let _ =` verschlucken! let res = db.delete_node(parent.id); assert!( res.is_err(), "Löschen muss fehlschlagen, wenn ein Kindknoten nicht gelöscht werden kann" ); let err_msg = res.unwrap_err().to_string(); assert!( err_msg.contains( "Fehler beim rekursiven Löschen: 1 Kindknoten konnten nicht gelöscht werden" ), "Fehlermeldung muss die Anzahl fehlgeschlagener Kindknoten exakt ausweisen: {}", err_msg ); } #[test] fn test_st03_recursive_delete_transaction_rollback() { let db = Database::open_in_memory().unwrap(); let (salt, kdf, wrapped_dek, nonce, tag) = ( [1u8; 16], KdfParams::default(), vec![2u8; 40], [3u8; 12], [4u8; 16], ); db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) .unwrap(); // 1. Ordner mit zwei Dateien anlegen let folder = db.create_node(1, "folder", true).unwrap(); let file1 = db.create_node(folder.id, "file1.txt", false).unwrap(); let file2 = db.create_node(folder.id, "file2.txt", false).unwrap(); // Chunks für file1 und file2 anlegen let chunk_data = vec![0x42u8; 1024]; let nonce = [1u8; 12]; let tag = [2u8; 16]; db.write_chunk(file1.id, 0, 1, &nonce, &tag, &chunk_data) .unwrap(); db.write_chunk(file2.id, 0, 1, &nonce, &tag, &chunk_data) .unwrap(); // 2. Trigger erstellen, der Löschen von file2 verhindert { let conn = db.conn(); conn.execute( "CREATE TRIGGER prevent_del_file2 BEFORE DELETE ON nodes WHEN OLD.name = 'file2.txt' BEGIN SELECT RAISE(FAIL, 'file2 ist schreibgeschützt'); END;", [], ) .unwrap(); } // 3. Löschen des gesamten Ordners aufrufen -> schlägt fehl let res = db.delete_node(folder.id); assert!(res.is_err(), "delete_node muss fehlschlagen"); // 4. ST-03 Atomaritätsprüfung (Rollback): // Weder folder noch file1 noch file2 dürfen gelöscht worden sein! assert!( db.get_node_by_id(folder.id).unwrap().is_some(), "Folder darf nach Rollback nicht gelöscht sein" ); assert!( db.get_node_by_id(file1.id).unwrap().is_some(), "file1 darf nach Rollback nicht gelöscht sein" ); assert!( db.get_node_by_id(file2.id).unwrap().is_some(), "file2 darf nach Rollback nicht gelöscht sein" ); // Chunk von file1 muss unversehrt existieren let chunk1 = db.read_chunk(file1.id, 0).unwrap(); assert!( chunk1.is_some(), "Chunk von file1 darf nach Rollback nicht gelöscht sein" ); assert_eq!( chunk1.unwrap().ciphertext, chunk_data, "Chunk von file1 muss unversehrt sein" ); // 5. Trigger entfernen und erneut löschen -> muss jetzt vollständig gelingen { let conn = db.conn(); conn.execute("DROP TRIGGER prevent_del_file2", []).unwrap(); } db.delete_node(folder.id) .expect("delete_node nach Trigger-Entfernung muss gelingen"); assert!( db.get_node_by_id(folder.id).unwrap().is_none(), "Folder muss gelöscht sein" ); assert!( db.get_node_by_id(file1.id).unwrap().is_none(), "file1 muss gelöscht sein" ); assert!( db.get_node_by_id(file2.id).unwrap().is_none(), "file2 muss gelöscht sein" ); assert!( db.read_chunk(file1.id, 0).unwrap().is_none(), "Chunk von file1 muss gelöscht sein" ); } }