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 crate::crypto::{ decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, 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 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) ); 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 { 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>, } fn current_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } 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()); } }; let db = Self { conn: Arc::new(Mutex::new(conn)), }; db.init_pragmas()?; 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)), }; db.init_pragmas()?; db.ensure_schema_upgrades()?; Ok(db) } pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn.lock().unwrap() } /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit. pub fn authenticate_password(&self, password: &str) -> Result> { 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); 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", []); 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.lock().unwrap(); 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.lock().unwrap(); 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.lock().unwrap(); let count: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0))?; Ok(count as usize) } /// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0). pub fn find_carrier_node_id(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1", )?; let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?; Ok(id) } /// 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.lock().unwrap(); 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). pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); 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.lock().unwrap(); 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 ); 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, 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, 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 conn.execute( "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) VALUES (1, ?1, 0, ?2, ?3, ?4)", params![c_name, c_size as i64, now, now], )?; let c_id = conn.last_insert_rowid(); // Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten) let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) 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 CarrierManifest für Block 0 let manifest = crate::carrier::CarrierManifest::new(total_blocks); let manifest_bytes = serde_json::to_vec(&manifest)?; let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk( dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, )?; let inner_ct_len = inner_ct.len() as u32; let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut outer_plaintext); outer_plaintext[0..12].copy_from_slice(&inner_nonce); outer_plaintext[12..28].copy_from_slice(&inner_tag); outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes()); let ct_end = 32 + inner_ct.len(); if ct_end > CHUNK_SIZE { bail!("Manifest-Payload zu groß für Block 0"); } outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk( dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) VALUES (?1, 0, ?2, ?3, ?4)", params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct], )?; // Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren let mut chunk_stmt = conn.prepare( "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) VALUES (?1, ?2, ?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 1..total_blocks { let (ct, nonce, tag) = crate::crypto::encrypt_chunk( dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, )?; 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;")?; 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 }; Ok(carrier_node_id) } /// 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], 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.lock().unwrap(); 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 ); 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, 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, 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 ununterscheidbares kryptografisches Rauschen (Plausible Deniability) 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(), ], )?; } Ok(()) } /// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability). 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). pub fn read_slots(&self) -> Result> { let conn = self.conn.lock().unwrap(); 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 rows = stmt.query_map([], |row| { 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)?; let mut kdf_salt = [0u8; 16]; if salt_vec.len() == 16 { kdf_salt.copy_from_slice(&salt_vec); } let mut header_nonce = [0u8; 12]; if nonce_vec.len() == 12 { header_nonce.copy_from_slice(&nonce_vec); } let mut header_tag = [0u8; 16]; if tag_vec.len() == 16 { header_tag.copy_from_slice(&tag_vec); } let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default(); Ok(SlotMeta { slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, }) })?; let mut slots = Vec::new(); for r in rows { slots.push(r?); } 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.lock().unwrap(); 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 { 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.lock().unwrap(); let params_json = serde_json::to_string(new_params)?; let rows_affected = conn.execute( "UPDATE meta SET kdf_salt = ?1, kdf_params = ?2, wrapped_dek = ?3, header_nonce = ?4, header_tag = ?5 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.lock().unwrap(); 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 } } /// 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.lock().unwrap(); 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 = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name); 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.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes WHERE id = ?1", )?; let record = stmt .query_row(params![id], |row| { let enc_name: String = row.get(2)?; let p_id: Option = row.get(1)?; let name = if vault_id == 1 { decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name) } else { enc_name }; Ok(NodeRecord { id: row.get(0)?, parent_id: row.get(1)?, name, 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()?; Ok(record) } 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.lock().unwrap(); 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| { let enc_name: String = row.get(2)?; let name = if vault_id == 1 { decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name) } else { enc_name }; Ok(NodeRecord { id: row.get(0)?, parent_id: row.get(1)?, name, 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 entries = Vec::new(); for r in rows { entries.push(r?); } 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 { let now = current_timestamp(); let conn = self.conn.lock().unwrap(); 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], ); 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.lock().unwrap(); conn.execute( "UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3", params![size as i64, modified_at as i64, id], )?; Ok(()) } /// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar. pub fn delete_node(&self, id: i64) -> Result<()> { // 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen let _ = self.shred_chunks_for_node(id); // 2. Shredde auch rekursiv alle Unterknoten let child_ids: Vec = { let conn = self.conn.lock().unwrap(); let mut stmt = conn.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 }; for child_id in child_ids { let _ = self.delete_node(child_id); } let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?; conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?; 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<()> { let now = current_timestamp(); let conn = self.conn.lock().unwrap(); 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], )?; 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.lock().unwrap(); let mut stmt = conn.prepare( "SELECT nonce, tag, ciphertext 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 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, nonce, tag, ciphertext, }) }) .optional()?; Ok(record) } /// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank. pub fn write_chunk( &self, node_id: i64, chunk_index: u32, nonce: &[u8; 12], tag: &[u8; 16], ciphertext: &[u8], ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(node_id, chunk_index) DO UPDATE SET nonce = excluded.nonce, tag = excluded.tag, ciphertext = excluded.ciphertext", params![ node_id, chunk_index, nonce.as_slice(), tag.as_slice(), ciphertext, ], )?; 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<()> { let mut conn = self.conn.lock().unwrap(); 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| { 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 AND chunk_index > ?2", params![node_id, max_chunk_index], )?; tx.commit()?; Ok(()) } /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. pub fn checkpoint(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); let _res: (i64, i64, i64) = conn.query_row( "PRAGMA wal_checkpoint(TRUNCATE);", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; 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.lock().unwrap(); 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.lock().unwrap(); 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 );", )?; 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) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", 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) VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ MAGIC_BYTES.as_slice(), meta.version, meta.kdf_salt.as_slice(), params_json, meta.wrapped_dek, meta.header_nonce.as_slice(), meta.header_tag.as_slice(), ], )?; } // 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( "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(), ], )?; } Ok(()) } /// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus. pub fn run_sqlite_integrity_check(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut issues = Vec::new(); // 1. PRAGMA integrity_check let mut stmt = conn.prepare("PRAGMA integrity_check;")?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; for r in rows { let msg = r?; if msg.to_lowercase() != "ok" { issues.push(format!("SQLite integrity error: {msg}")); } } // 2. PRAGMA foreign_key_check let mut fk_stmt = conn.prepare("PRAGMA foreign_key_check;")?; let fk_rows = fk_stmt.query_map([], |row| { let table: String = row.get(0)?; let rowid: i64 = row.get(1)?; let parent: String = row.get(2)?; let fkid: i64 = row.get(3)?; Ok(format!( "Foreign Key Verletzung in Tabelle '{table}', RowId {rowid}, Ziel '{parent}', FK #{fkid}" )) })?; for r in fk_rows { issues.push(r?); } Ok(issues) } /// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container. pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> { let conn = self.conn.lock().unwrap(); let dirs: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| { r.get(0) })?; let files: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| { r.get(0) })?; let chunks: i64 = conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?; Ok((dirs as usize, files as usize, chunks as usize)) } /// Listet alle Knoten (Dateien und Ordner) im gesamten Baum auf. pub fn list_all_nodes(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes ORDER BY id ASC", )?; let rows = stmt.query_map([], |row| { Ok(NodeRecord { id: row.get(0)?, parent_id: row.get(1)?, name: row.get(2)?, is_dir: row.get::<_, i32>(3)? != 0, size: row.get::<_, i64>(4)? as u64, created_at: row.get::<_, i64>(5)? as u64, modified_at: row.get::<_, i64>(6)? as u64, }) })?; let mut result = Vec::new(); for r in rows { result.push(r?); } Ok(result) } /// Liefert alle vorhandenen Chunk-Identifikatoren (node_id, chunk_index). pub fn list_all_chunk_headers(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT node_id, chunk_index FROM chunks ORDER BY node_id, chunk_index")?; let rows = stmt.query_map([], |row| { let nid: i64 = row.get(0)?; let cidx: u32 = row.get(1)?; Ok((nid, cidx)) })?; let mut result = Vec::new(); for r in rows { result.push(r?); } Ok(result) } } #[cfg(test)] 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, &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, &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, &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_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.lock().unwrap(); 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(); // 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"); } }