From ad531d839308912b27b91af610106d67ada0e820 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 00:26:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(crypto):=20K-01=20=E2=80=94=20format=20v3?= =?UTF-8?q?=20canonical=20metadata=20authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/crypto.rs | 112 ++++++++++++++- src/main.rs | 55 +++++++- src/storage.rs | 224 +++++++++++++++++++++++++++++- src/sync.rs | 2 + src/verify.rs | 30 +++- src/vfs.rs | 2 + tests/untrusted_container_test.rs | 196 +++++++++++++++++++++++++- 7 files changed, 609 insertions(+), 12 deletions(-) diff --git a/src/crypto.rs b/src/crypto.rs index 82c3ace..c68710a 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -7,12 +7,15 @@ use argon2::{Algorithm, Argon2, Params, Version}; use rand::rngs::OsRng; use rand::RngCore; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use zeroize::Zeroizing; pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0"; pub const FORMAT_VERSION_V1: u32 = 1; pub const FORMAT_VERSION_V2: u32 = 2; -pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2; +pub const FORMAT_VERSION_V3: u32 = 3; +pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V3; pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB /// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2 @@ -554,6 +557,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result> { Ok(dek) } +/// Berechnet HMAC-SHA256 gemäß RFC 2104. +pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { + let mut key_block = [0u8; 64]; + if key.len() > 64 { + let mut hasher = Sha256::new(); + hasher.update(key); + let hash = hasher.finalize(); + key_block[..32].copy_from_slice(&hash); + } else { + key_block[..key.len()].copy_from_slice(key); + } + + let mut k_ipad = [0u8; 64]; + let mut k_opad = [0u8; 64]; + for i in 0..64 { + k_ipad[i] = key_block[i] ^ 0x36; + k_opad[i] = key_block[i] ^ 0x5c; + } + + let mut inner_hasher = Sha256::new(); + inner_hasher.update(&k_ipad); + inner_hasher.update(data); + let inner_hash = inner_hasher.finalize(); + + let mut outer_hasher = Sha256::new(); + outer_hasher.update(&k_opad); + outer_hasher.update(&inner_hash); + let out = outer_hasher.finalize(); + + let mut result = [0u8; 32]; + result.copy_from_slice(&out); + result +} + +/// Leitet einen 32-Byte Schlüssel mittels HKDF-SHA256 (RFC 5869) aus IKM und Info-String ab. +pub fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] { + let salt = [0u8; 32]; + let prk = hmac_sha256(&salt, ikm); + + let mut expand_input = Vec::with_capacity(info.len() + 1); + expand_input.extend_from_slice(info); + expand_input.push(0x01); + + hmac_sha256(&prk, &expand_input) +} + +/// Leitet den dedizierten Metadaten-MAC-Schlüssel aus dem DEK ab (Format V3 / K-01). +pub fn derive_metadata_mac_key(dek: &[u8; 32]) -> [u8; 32] { + hkdf_sha256(dek, b"SANCTUM_META_MAC_V3") +} + +/// Berechnet den Metadaten-MAC über kanonisch serialisierte Knoten und Generation (Format V3 / K-01). +pub fn compute_metadata_mac( + mac_key: &[u8; 32], + metadata_gen: u64, + canonical_nodes: &[u8], +) -> [u8; 32] { + let mut data = Vec::with_capacity(24 + canonical_nodes.len()); + data.extend_from_slice(b"SANCTUM_META_V3\0"); + data.extend_from_slice(&metadata_gen.to_le_bytes()); + data.extend_from_slice(canonical_nodes); + hmac_sha256(mac_key, &data) +} + +/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC gültig ist (Format V3 / K-01). +pub fn verify_metadata_mac( + mac_key: &[u8; 32], + metadata_gen: u64, + canonical_nodes: &[u8], + expected_mac: &[u8; 32], +) -> bool { + let computed = compute_metadata_mac(mac_key, metadata_gen, canonical_nodes); + computed.ct_eq(expected_mac).into() +} + /// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern: /// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian). #[inline] @@ -1097,4 +1175,36 @@ mod tests { err_msg ); } + + #[test] + fn test_hmac_sha256_rfc4231_test_case_1() { + // RFC 4231 Test Case 1: Key = 20x 0x0b, Data = "Hi There" + let key = [0x0b; 20]; + let data = b"Hi There"; + let mac = hmac_sha256(&key, data); + let expected_hex = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; + assert_eq!(hex::encode(mac), expected_hex); + } + + #[test] + fn test_metadata_mac_verification() { + let dek = generate_dek(); + let mac_key = derive_metadata_mac_key(&dek); + let canonical_nodes = b"node_canonical_bytes_mock_12345"; + let gen = 0u64; + + let mac = compute_metadata_mac(&mac_key, gen, canonical_nodes); + assert!(verify_metadata_mac(&mac_key, gen, canonical_nodes, &mac)); + + // Manipulierte Generation -> ungültig + assert!(!verify_metadata_mac(&mac_key, gen + 1, canonical_nodes, &mac)); + + // Manipulierte Knoten-Bytes -> ungültig + assert!(!verify_metadata_mac(&mac_key, gen, b"tampered_nodes", &mac)); + + // Falscher Schlüssel -> ungültig + let wrong_dek = generate_dek(); + let wrong_key = derive_metadata_mac_key(&wrong_dek); + assert!(!verify_metadata_mac(&wrong_key, gen, canonical_nodes, &mac)); + } } diff --git a/src/main.rs b/src/main.rs index 61e6d59..dae3275 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use zeroize::Zeroizing; use sanctum::crypto::{ check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload, - wrap_slot1_payload, KdfParams, FORMAT_VERSION, + wrap_slot1_payload, KdfParams, FORMAT_VERSION, FORMAT_VERSION_V3, }; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::recovery::{ @@ -280,6 +280,13 @@ enum Commands { #[arg(long, default_value_t = false)] insecure_url: bool, }, + + /// Migriert einen bestehenden Container auf Format V3 (Metadaten-Authentifizierung & Replay-Schutz) + UpgradeFormat { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + }, } fn parse_drive_letter(s: &str) -> Result { @@ -623,6 +630,7 @@ fn handle_init( let db = Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?; + db.set_active_dek(dek.clone()); db.init_schema_with_carrier( &salt, &kdf_params, @@ -1214,6 +1222,48 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { Ok(()) } +fn handle_upgrade_format(container_path: &Path) -> Result<()> { + if !container_path.exists() { + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); + } + + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Format V3 Upgrade │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + + let password = rpassword::prompt_password("Master-Passwort: ") + .context("Fehler beim Einlesen des Passworts")?; + + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; + let keys = match db.authenticate_password(&password)? { + Some(k) => k, + None => { + bail!("Authentifizierung fehlgeschlagen: Falsches Passwort"); + } + }; + + if keys.version() >= FORMAT_VERSION_V3 { + println!( + " • Container ist bereits Format V{} (keine Migration erforderlich).", + keys.version() + ); + return Ok(()); + } + + db.upgrade_to_v3(keys.dek())?; + db.checkpoint()?; + + println!(" ✔ Container erfolgreich auf Format V3 aktualisiert!"); + println!(" • Metadaten-Authentifizierung (HMAC-SHA256) aktiviert."); + println!(" • Replay-Schutz für Chunks (24-Byte AAD mit Generation) aktiviert."); + Ok(()) +} + fn handle_sync( container_path: &Path, source: &str, @@ -1520,6 +1570,9 @@ async fn run() -> Result<()> { Commands::Verify { path, full } => { handle_verify(&path, full)?; } + Commands::UpgradeFormat { path } => { + handle_upgrade_format(&path)?; + } Commands::Sync { path, source, diff --git a/src/storage.rs b/src/storage.rs index 7c80dd3..33d7780 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -8,9 +8,10 @@ use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use crate::crypto::{ - decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload, - validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, - FORMAT_VERSION_V2, MAGIC_BYTES, + 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; @@ -184,6 +185,7 @@ impl ContainerMeta { #[derive(Clone)] pub struct Database { conn: Arc>, + active_dek: Arc>>>, } fn current_timestamp() -> u64 { @@ -304,6 +306,7 @@ impl Database { let db = Self { conn: Arc::new(Mutex::new(conn)), + active_dek: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; if table_count > 0 { @@ -318,6 +321,7 @@ impl Database { let conn = Connection::open_in_memory()?; let db = Self { conn: Arc::new(Mutex::new(conn)), + active_dek: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; db.ensure_schema_upgrades()?; @@ -328,11 +332,22 @@ impl Database { self.conn.lock().unwrap() } + /// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01). + pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) { + *self.active_dek.lock().unwrap() = Some(dek); + } + + /// Gibt den aktuellen aktiven DEK zurück, falls gesetzt. + pub fn active_dek(&self) -> Option> { + self.active_dek.lock().unwrap().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_dek(keys.dek().clone()); if let Some(cid) = keys.carrier_node_id() { let _ = self.mark_carrier_node_id(cid); } @@ -363,6 +378,15 @@ impl Database { [], ); + // 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", + [], + ); + Ok(()) } @@ -526,7 +550,9 @@ impl Database { kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, header_nonce BLOB NOT NULL, - header_tag BLOB NOT NULL + header_tag BLOB NOT NULL, + metadata_mac BLOB, + metadata_gen INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS nodes ( @@ -671,6 +697,7 @@ impl Database { } conn.execute_batch("COMMIT;")?; + self.set_active_dek(Zeroizing::new(*dek_0)); Some(c_id) } else { // Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A) @@ -693,6 +720,9 @@ impl Database { None }; + drop(conn); + let _ = self.update_metadata_mac(); + Ok(carrier_node_id) } @@ -721,7 +751,9 @@ impl Database { kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, header_nonce BLOB NOT NULL, - header_tag BLOB NOT NULL + header_tag BLOB NOT NULL, + metadata_mac BLOB, + metadata_gen INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS nodes ( @@ -812,6 +844,9 @@ impl Database { )?; } + drop(conn); + let _ = self.update_metadata_mac(); + Ok(()) } @@ -969,7 +1004,10 @@ impl Database { bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein"); } - if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 { + if slot0.version != FORMAT_VERSION_V1 + && slot0.version != FORMAT_VERSION_V2 + && slot0.version != FORMAT_VERSION_V3 + { bail!( "Nicht unterstützte Sanctum-Formatversion: {}", slot0.version @@ -1289,6 +1327,9 @@ impl Database { params![now, parent_id], ); + drop(conn); + let _ = self.update_metadata_mac(); + Ok(NodeRecord { id: new_id, parent_id: Some(parent_id), @@ -1312,6 +1353,8 @@ impl Database { "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(()) } @@ -1362,6 +1405,8 @@ impl Database { 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])?; + drop(conn); + let _ = self.update_metadata_mac(); Ok(()) } @@ -1388,6 +1433,8 @@ impl Database { "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(()) } @@ -1493,6 +1540,8 @@ impl Database { params![new_size as i64, modified_at as i64, node_id], )?; tx.commit()?; + drop(conn); + let _ = self.update_metadata_mac(); Ok(()) } @@ -1540,6 +1589,169 @@ impl Database { params![node_id, max_chunk_index], )?; tx.commit()?; + drop(conn); + let _ = self.update_metadata_mac(); + Ok(()) + } + + /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). + pub fn canonical_nodes_bytes(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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 + ORDER BY n.id ASC", + )?; + + let mut rows = stmt.query([])?; + let mut buf = Vec::new(); + + while let Some(row) = rows.next()? { + 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(buf) + } + + /// Aktualisiert den Metadaten-MAC in Slot 0 bei strukturellen Modifikationen (Format V3 / K-01). + pub fn update_metadata_mac(&self) -> Result<()> { + let dek_opt = self.active_dek.lock().unwrap().clone(); + let Some(dek) = dek_opt else { + return Ok(()); + }; + + let conn = self.conn.lock().unwrap(); + let version_and_gen: Option<(u32, u64)> = conn + .query_row( + "SELECT version, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1", + [], + |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()?; + let mac_key = derive_metadata_mac_key(&dek); + let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = 0", + params![new_mac.as_slice(), next_gen], + )?; + + 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 { + let conn = self.conn.lock().unwrap(); + let meta_row: Option<(u32, Option>, u64)> = conn + .query_row( + "SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1", + [], + |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(true); + }; + + if version < FORMAT_VERSION_V3 { + return Ok(true); + } + + let Some(mac_bytes) = mac_opt else { + return Ok(false); + }; + + if mac_bytes.len() != 32 { + return Ok(false); + } + + let mut expected_mac = [0u8; 32]; + expected_mac.copy_from_slice(&mac_bytes); + drop(conn); + + let canonical = self.canonical_nodes_bytes()?; + let mac_key = derive_metadata_mac_key(dek); + Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac)) + } + + /// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02). + pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> { + let conn = self.conn.lock().unwrap(); + 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", + [], + ); + + 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(()) } diff --git a/src/sync.rs b/src/sync.rs index ef239dc..2dae857 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -439,6 +439,8 @@ pub fn run_sync( let start_time = Instant::now(); let mut stats = SyncStats::default(); + db.set_active_dek(zeroize::Zeroizing::new(*dek)); + match options.direction { SyncDirection::Push => { sync_push( diff --git a/src/verify.rs b/src/verify.rs index c780781..c87af2e 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -4,7 +4,9 @@ use std::path::Path; use anyhow::{bail, Context, Result}; use zeroize::Zeroizing; -use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2}; +use crate::crypto::{ + decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3, +}; use crate::storage::Database; /// Bericht über das Ergebnis einer Container-Integritätsprüfung. @@ -81,7 +83,10 @@ pub fn verify_container( let meta = match db.read_meta() { Ok(m) => { report.format_version = m.version; - if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 { + if m.version != FORMAT_VERSION_V1 + && m.version != FORMAT_VERSION_V2 + && m.version != FORMAT_VERSION_V3 + { report.header_ok = false; report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version)); } @@ -247,6 +252,25 @@ pub fn verify_container( None }; + // 3b. Metadaten-Authentifizierung (HMAC-SHA256) für Format V3 (K-01) + if let Some(active_dek) = dek { + if let Some(ref m) = meta { + if m.version >= FORMAT_VERSION_V3 { + match db.verify_metadata_mac(active_dek) { + Ok(true) => {} + Ok(false) => { + report.errors.push( + "Kritischer Metadaten-Authentifizierungsfehler (K-01): Metadaten-MAC ungültig. Manipulationsversuch an Dateinamen, Größen oder Verzeichnisstruktur in der SQLite-Datenbank erkannt.".to_string(), + ); + } + Err(e) => { + report.errors.push(format!("Fehler bei der Metadaten-MAC-Prüfung: {e}")); + } + } + } + } + } + // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung let chunk_headers = db .list_all_chunk_headers() @@ -339,6 +363,7 @@ mod tests { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); + db.set_active_dek(dek.clone()); db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); @@ -401,6 +426,7 @@ mod tests { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); + db.set_active_dek(dek.clone()); db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) .unwrap(); diff --git a/src/vfs.rs b/src/vfs.rs index 622fd0f..b83b3db 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -490,6 +490,8 @@ impl SanctumFs { let dek_arc = Arc::new(dek); let carrier_dek_arc = carrier_dek.map(Arc::new); + db.set_active_dek((*dek_arc).clone()); + // Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist, // um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen. let carrier_node_id = carrier_node_id.or_else(|| { diff --git a/tests/untrusted_container_test.rs b/tests/untrusted_container_test.rs index 6a10a96..2bc8f70 100644 --- a/tests/untrusted_container_test.rs +++ b/tests/untrusted_container_test.rs @@ -1,8 +1,9 @@ use sanctum::crypto::{ - derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB, - MIN_TIME_COST, + derive_kek, generate_dek, generate_salt, wrap_dek, wrap_slot0_payload, KdfParams, + MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::storage::Database; +use sanctum::verify::verify_container; use std::path::PathBuf; #[test] @@ -677,3 +678,194 @@ fn test_container_meta_authenticate_defense_in_depth() { }; assert!(meta_valid_1.authenticate("TestPassword2026!").is_some()); } + +#[test] +fn test_k01_metadata_tampering_detected_by_verify() { + let temp_dir = std::env::temp_dir(); + let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id())); + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + let password = "SecretMasterPassword2026!"; + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let salt = generate_salt(); + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap(); + + let db = Database::open(&db_path).unwrap(); + db.set_active_dek(dek.clone()); + db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None) + .unwrap(); + + // Knoten anlegen + let node = db + .create_node(1, "secret_financials.xlsx", false) + .unwrap(); + db.update_node_size_and_time(node.id, 50000, 1000).unwrap(); + db.checkpoint().unwrap(); + + // 1. Initialer Zustand: Verifikation muss erfolgreich sein + let report = verify_container(&db_path, Some(&dek), true).unwrap(); + assert!( + report.is_healthy(), + "Initialer V3-Container muss gesund sein, aber Fehler: {:?}", + report.errors + ); + + // 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können) + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';", + [], + ) + .unwrap(); + } + + // Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden! + let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap(); + assert!( + !tampered_report.is_healthy(), + "Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!" + ); + assert!( + tampered_report + .errors + .iter() + .any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")), + "Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}", + tampered_report.errors + ); + + // 3. Angreifer manipuliert Dateinamen direkt in SQLite + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;", + [node.id], + ) + .unwrap(); + } + + let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap(); + assert!( + !tampered_name_report.is_healthy(), + "Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!" + ); + assert!( + tampered_name_report + .errors + .iter() + .any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")), + "Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}", + tampered_name_report.errors + ); + + let _ = std::fs::remove_file(&db_path); +} + +#[test] +fn test_k01_upgrade_format_v2_to_v3() { + let temp_dir = std::env::temp_dir(); + let db_path: PathBuf = temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id())); + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + let password = "UpgradeMasterPassword2026!"; + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let salt = generate_salt(); + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap(); + + // Erzeuge bewusst einen V2-Container ohne metadata_mac + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE 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 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 + ); + CREATE TABLE 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) + );", + ) + .unwrap(); + + let params_json = serde_json::to_string(&kdf_params).unwrap(); + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + sanctum::crypto::MAGIC_BYTES.as_slice(), + salt.as_slice(), + params_json, + wrapped_dek, + nonce.as_slice(), + tag.as_slice(), + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);", + [], + ) + .unwrap(); + } + + let db = Database::open(&db_path).unwrap(); + let meta_before = db.read_meta().unwrap(); + assert_eq!(meta_before.version, 2); + + // Upgrade auf Format V3 durchführen + db.upgrade_to_v3(&dek).unwrap(); + db.checkpoint().unwrap(); + + let meta_after = db.read_meta().unwrap(); + assert_eq!(meta_after.version, 3); + + // V3-Container muss nach dem Upgrade integer und gesund sein + let report = verify_container(&db_path, Some(&dek), true).unwrap(); + assert!( + report.is_healthy(), + "Container nach upgrade_to_v3 muss gesund sein: {:?}", + report.errors + ); + + let _ = std::fs::remove_file(&db_path); +} +