From 8455dc02dbc10a12479804643ba46f812ce11d24 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 11:59:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(carrier):=20C-02=20=E2=80=94=20rolling=20du?= =?UTF-8?q?al-block=20manifest=20redundancy=20and=20failover=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/carrier.rs | 98 ++++++++++++++-- src/storage.rs | 59 +++++++--- tests/carrier_model_a_test.rs | 210 ++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 28 deletions(-) diff --git a/src/carrier.rs b/src/carrier.rs index 5d48aed..010e119 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -18,7 +18,7 @@ use futures_util::stream; use rand::rngs::OsRng; use rand::RngCore; use serde::{Deserialize, Serialize}; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use zeroize::{Zeroize, Zeroizing}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; @@ -28,11 +28,13 @@ use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData}; pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR"; pub const CARRIER_VERSION: u32 = 1; -/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0). +/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CarrierManifest { pub magic: [u8; 8], pub version: u32, + #[serde(default)] + pub manifest_generation: u64, pub total_blocks: u32, pub free_blocks: Vec, pub next_inode_id: i64, @@ -62,12 +64,13 @@ impl CarrierManifest { }, ); - // Block 0 ist für das Manifest reserviert. Nutzblöcke sind 1..total_blocks-1. - let free_blocks = (1..total_blocks).collect(); + // Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1. + let free_blocks = (2..total_blocks).collect(); Self { magic: *CARRIER_MAGIC, version: CARRIER_VERSION, + manifest_generation: 0, total_blocks, free_blocks, next_inode_id: 2, @@ -282,6 +285,7 @@ impl CarrierFsInner { self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) } pub fn save_manifest(&mut self) -> Result<()> { + self.manifest.manifest_generation += 1; let manifest_bytes = serde_json::to_vec(&self.manifest)?; if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) { bail!( @@ -297,10 +301,19 @@ impl CarrierFsInner { crate::crypto::CHUNK_SIZE - 64 ); } + + // C-02: Rollierendes Dual-Block-Manifest. + // Gerade Generation -> Block 0, ungerade Generation -> Block 1. + let target_block = if self.manifest.manifest_generation % 2 == 0 { + 0 + } else { + 1 + }; + write_carrier_block( &self.db, self.carrier_node_id, - 0, + target_block, &self.dek_outer, &self.dek_inner, &manifest_bytes, @@ -374,7 +387,8 @@ pub struct CarrierFs { } impl CarrierFs { - /// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04). + /// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Block-0/Block-1 Prüfung (C-02) + /// und benutzerdefinierten Anti-Leak-Regeln (V-04). pub fn load_with_leak_rules( db: Database, carrier_node_id: i64, @@ -384,19 +398,79 @@ impl CarrierFs { anti_leak: bool, custom_leak_rules: Vec, ) -> Result { - let manifest_bytes = read_carrier_block( + // C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren + let block_0_res = read_carrier_block( &db, carrier_node_id, 0, &dek_outer, &dek_inner, format_version, - )?; + ) + .and_then(|bytes| { + let m: CarrierManifest = serde_json::from_slice(&bytes)?; + if m.magic != *CARRIER_MAGIC { + bail!("Ungültige Carrier-Magic-Bytes in Block 0"); + } + Ok(m) + }); - let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?; - if manifest.magic != *CARRIER_MAGIC { - bail!("Ungültige Carrier-Magic-Bytes in Block 0"); - } + let block_1_res = read_carrier_block( + &db, + carrier_node_id, + 1, + &dek_outer, + &dek_inner, + format_version, + ) + .and_then(|bytes| { + let m: CarrierManifest = serde_json::from_slice(&bytes)?; + if m.magic != *CARRIER_MAGIC { + bail!("Ungültige Carrier-Magic-Bytes in Block 1"); + } + Ok(m) + }); + + let mut manifest = match (block_0_res, block_1_res) { + (Ok(m0), Ok(m1)) => { + if m1.manifest_generation > m0.manifest_generation { + debug!( + "C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})", + m1.manifest_generation, m0.manifest_generation + ); + m1 + } else { + debug!( + "C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})", + m0.manifest_generation, m1.manifest_generation + ); + m0 + } + } + (Ok(m0), Err(e1)) => { + debug!( + "C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}", + m0.manifest_generation, e1 + ); + m0 + } + (Err(e0), Ok(m1)) => { + warn!( + "C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).", + e0, m1.manifest_generation + ); + m1 + } + (Err(e0), Err(e1)) => { + bail!( + "Kritischer Datenverlust (C-02): Weder Block 0 ({}) noch Block 1 ({}) enthalten ein gültiges Carrier-Manifest! Hidden Vault nicht wiederherstellbar.", + e0, e1 + ); + } + }; + + // Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren + manifest.free_blocks.retain(|&b| b >= 2); let now = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/storage.rs b/src/storage.rs index d7fd078..6f3e370 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -731,36 +731,63 @@ impl Database { ], )?; - // Initialisiere CarrierManifest für Block 0 + // Initialisiere CarrierManifest für redundante Blöcke 0 und 1 (C-02) let manifest = crate::carrier::CarrierManifest::new(total_blocks); let manifest_bytes = serde_json::to_vec(&manifest)?; - let (inner_ct, inner_nonce, inner_tag) = + // 1. Block 0 schreiben (Primärkopie, Gen 0) + let (inner_ct_0, inner_nonce_0, inner_tag_0) = crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?; - let inner_ct_len = inner_ct.len() as u32; + let inner_ct_len_0 = inner_ct_0.len() as u32; - let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; - OsRng.fill_bytes(&mut outer_plaintext); + let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut outer_plaintext_0); - 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 { + 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!("Manifest-Payload zu groß für Block 0"); } - outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); + outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0); - let (outer_ct, outer_nonce, outer_tag) = - crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 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.as_slice(), outer_tag.as_slice(), outer_ct], + params![c_id, outer_nonce_0.as_slice(), outer_tag_0.as_slice(), outer_ct_0], )?; - // Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren + // 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02) + let (inner_ct_1, inner_nonce_1, inner_tag_1) = + crate::crypto::encrypt_chunk(dek_1, c_id, 1, &manifest_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!("Manifest-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], + )?; + + // Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02) let mut chunk_stmt = conn.prepare( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, ?2, 0, ?3, ?4, ?5)", @@ -770,7 +797,7 @@ impl Database { OsRng.fill_bytes(&mut dummy_noise); conn.execute_batch("BEGIN TRANSACTION;")?; - for b in 1..total_blocks { + for b in 2..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,])?; diff --git a/tests/carrier_model_a_test.rs b/tests/carrier_model_a_test.rs index d58e3bc..4dc6014 100644 --- a/tests/carrier_model_a_test.rs +++ b/tests/carrier_model_a_test.rs @@ -923,3 +923,213 @@ fn test_m05_deniability_schema_equality_standard_vs_hidden() { let _ = std::fs::remove_file(&path_std); let _ = std::fs::remove_file(&path_hidden); } + +#[tokio::test] +async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() { + let path = temp_db_path("c02_rolling_manifest"); + let carrier_size_bytes = 10 * 1024 * 1024; + let carrier_name = "test_rolling.dat"; + let pass_decoy = "DecoyPassword2026!"; + let pass_hidden = "SuperSecretHiddenPassword2026!"; + + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + + let salt_0 = generate_salt(); + let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap(); + let dek_0 = generate_dek(); + + let salt_1 = generate_salt(); + let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap(); + let dek_1 = generate_dek(); + + let carrier_node_id = 3i64; + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_1, nonce_1, tag_1) = + wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + + let db = Database::open(&path).expect("Open database"); + db.init_schema_with_carrier( + &salt_0, + &kdf_params, + &wrapped_0, + &nonce_0, + &tag_0, + Some(( + carrier_name, + carrier_size_bytes, + &salt_1, + &kdf_params, + &wrapped_1, + &nonce_1, + &tag_1, + &dek_0, + &dek_1, + )), + ) + .expect("Init carrier schema"); + db.checkpoint().unwrap(); + + let meta = db.read_meta().unwrap(); + let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden"); + + let cfs = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("Initial load of dual-block manifest"); + + // Datei anlegen -> save_manifest() -> Gen 1 -> schreibt Block 1 + let file1_path = DavPath::new("/file_gen1.txt").unwrap(); + let open_write = OpenOptions { + create_new: true, + write: true, + ..Default::default() + }; + let mut file1 = cfs.open(&file1_path, open_write).await.expect("Create file1"); + file1.write_bytes(Bytes::from_static(b"generation 1 content")).await.unwrap(); + file1.flush().await.unwrap(); + drop(file1); + drop(cfs); + + // Verifiziere: Block 1 hat jetzt Gen 1 + let cfs_reloaded = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("Reload higher generation from block 1"); + + // Zweite Datei anlegen -> save_manifest() -> Gen 2 -> schreibt Block 0 + let file2_path = DavPath::new("/file_gen2.txt").unwrap(); + let open_write2 = OpenOptions { + create_new: true, + write: true, + ..Default::default() + }; + let mut file2 = cfs_reloaded.open(&file2_path, open_write2).await.expect("Create file2"); + file2.write_bytes(Bytes::from_static(b"generation 2 content")).await.unwrap(); + file2.flush().await.unwrap(); + drop(file2); + drop(cfs_reloaded); + + // 2. Simuliere Bitrot auf Block 0 (gezielt mit Rauschen überschreiben) + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let mut noise = vec![0u8; 100]; + OsRng.fill_bytes(&mut noise); + conn.execute( + "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0", + rusqlite::params![noise, carrier_node_id], + ) + .unwrap(); + } + + // Laden MUSS über Block 1 gelingen (Failover-Schutz) + let cfs_recovered_from_b1 = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("Recovery from Block 1 must succeed despite damaged Block 0"); + + assert!(cfs_recovered_from_b1.metadata(&file1_path).await.is_ok()); + drop(cfs_recovered_from_b1); + + // 3. Simuliere Beschädigung von Block 1, während Block 0 intakt ist + let cfs_repair = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .unwrap(); + let file3_path = DavPath::new("/file_gen3.txt").unwrap(); + let mut file3 = cfs_repair + .open( + &file3_path, + OpenOptions { + create_new: true, + write: true, + ..Default::default() + }, + ) + .await + .unwrap(); + file3.write_bytes(Bytes::from_static(b"gen 3")).await.unwrap(); + file3.flush().await.unwrap(); + drop(file3); + drop(cfs_repair); + + // Jetzt Block 1 beschädigen + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let mut noise = vec![0u8; 100]; + OsRng.fill_bytes(&mut noise); + conn.execute( + "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 1", + rusqlite::params![noise, carrier_node_id], + ) + .unwrap(); + } + + // Laden MUSS über Block 0 gelingen + let cfs_recovered_from_b0 = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("Recovery from Block 0 must succeed despite damaged Block 1"); + drop(cfs_recovered_from_b0); + + // 4. Wenn BEIDE Blöcke zerstört sind: Totalverlust mit klarer Fehlermeldung + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let mut noise = vec![0u8; 100]; + OsRng.fill_bytes(&mut noise); + conn.execute( + "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0", + rusqlite::params![noise, carrier_node_id], + ) + .unwrap(); + } + let both_damaged = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ); + let err_msg = match both_damaged { + Ok(_) => panic!("Both blocks damaged must return Error"), + Err(e) => e.to_string(), + }; + assert!( + err_msg.contains("Kritischer Datenverlust (C-02)"), + "Error message must clearly identify C-02 failure: {}", + err_msg + ); + + drop(db); + let _ = std::fs::remove_file(&path); +} +