From dd6b6ff94d191ec925036b7060dbbdb7379045d3 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 23:19:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(storage):=20ST-01=20=E2=80=94=20fail-closed?= =?UTF-8?q?=20node=20name=20decryption=20filtering=20out=20corrupted=20ent?= =?UTF-8?q?ries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/storage.rs | 211 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 175 insertions(+), 36 deletions(-) diff --git a/src/storage.rs b/src/storage.rs index 6567621..f661b90 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -6,6 +6,7 @@ use anyhow::{bail, Result}; use rand::rngs::OsRng; use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; +use tracing::warn; use crate::crypto::{ compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key, @@ -1433,8 +1434,16 @@ impl Database { 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); + 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, @@ -1482,29 +1491,49 @@ impl Database { FROM nodes WHERE id = ?1", )?; - let record = stmt + let raw = 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) + 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(NodeRecord { - id: row.get(0)?, - parent_id: row.get(1)?, + Ok(Some(NodeRecord { + id, + parent_id, 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) + is_dir, + size, + created_at, + modified_at, + })) + } + None => Ok(None), + } } pub fn get_node_by_id(&self, id: i64) -> Result> { @@ -1527,27 +1556,44 @@ impl Database { )?; 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, - }) + 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 { - entries.push(r?); + 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) } @@ -2972,4 +3018,97 @@ mod tests { 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"); + } }