use rand::rngs::OsRng; use rand::RngCore; use sanctum::crypto::{ decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION_V2, FORMAT_VERSION_V3, MAGIC_BYTES, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::mount::{mount_container, ContainerAuth}; use sanctum::recovery::{export_header_backup, restore_header_backup}; use sanctum::storage::{Database, MetadataMacStatus}; use std::path::PathBuf; use std::time::Duration; use zeroize::Zeroizing; fn test_kdf_params() -> KdfParams { KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1, } } fn temp_container_path(prefix: &str) -> PathBuf { let mut path = std::env::temp_dir(); let rand_val: u64 = OsRng.next_u64(); path.push(format!("sanctum_{prefix}_{rand_val}.sanctum")); path } // ----------------------------------------------------------------------------- // F-02 TESTS: PendingRebuild Token & Rebuild-MAC Autorisation // ----------------------------------------------------------------------------- #[tokio::test] async fn test_f02_pending_rebuild_without_token_fails() { let path = temp_container_path("f02_no_token"); let password = "TestPasswordF02_1!"; let salt = generate_salt(); let kdf = test_kdf_params(); let kek = derive_kek(password, &salt, &kdf).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&path).unwrap(); db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap(); db.set_active_slot_and_dek(0, dek.clone()); let _ = db.create_node(1, "test.txt", false).unwrap(); db.update_metadata_mac().unwrap(); db.checkpoint().unwrap(); drop(db); // Angreifer manipuliert meta: metadata_mac = NULL, metadata_gen = 0 (ohne Token) { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute( "UPDATE meta SET metadata_mac = NULL, metadata_gen = 0, restore_nonce = NULL WHERE slot_id = 0", [], ) .unwrap(); } // 1. Status muss strikt Invalid sein (kein PendingRebuild!) let check_db = Database::open(&path).unwrap(); let status = check_db .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!( status, MetadataMacStatus::Invalid, "NULL-MAC ohne restore_nonce Token muss strikt Invalid sein (F-02 Bypass-Schutz)" ); assert!( !check_db.verify_metadata_mac_for_slot(0, &dek).unwrap(), "verify_metadata_mac_for_slot muss false liefern" ); drop(check_db); // 2. Mount ohne Flag muss fehlschlagen let auth = ContainerAuth::Password(Zeroizing::new(password.to_string())); let res = mount_container( &path, 'Z', None, Some(18101), auth, false, false, None, false, false, None, true, false, ) .await; assert!(res.is_err(), "Mount muss bei Status Invalid abbrechen"); // 3. Selbst mit --rebuild-mac darf KEIN Rebuild erfolgen, wenn Status Invalid ist! let auth2 = ContainerAuth::Password(Zeroizing::new(password.to_string())); let res2 = mount_container( &path, 'Z', None, Some(18102), auth2, false, false, None, false, false, None, true, true, ) .await; assert!( res2.is_err(), "Mount mit --rebuild-mac darf Invalid-Metadaten nicht neu signieren" ); // 4. Metadaten-MAC bleibt weiterhin ungesetzt let verify_db = Database::open(&path).unwrap(); let status_after = verify_db .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!(status_after, MetadataMacStatus::Invalid); let _ = std::fs::remove_file(&path); } #[tokio::test] async fn test_f02_restore_header_without_flag_fails() { let path = temp_container_path("f02_restore_noflag"); let backup_path = path.with_extension("hdr"); let password = "TestPasswordF02_2!"; let salt = generate_salt(); let kdf = test_kdf_params(); let kek = derive_kek(password, &salt, &kdf).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&path).unwrap(); db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap(); db.set_active_slot_and_dek(0, dek.clone()); let _ = db.create_node(1, "file.txt", false).unwrap(); db.update_metadata_mac().unwrap(); db.checkpoint().unwrap(); drop(db); // Backup exportieren export_header_backup(&path, &backup_path).unwrap(); // Header zerstören und restaurieren { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute("DELETE FROM meta", []).unwrap(); } restore_header_backup(&path, &backup_path).unwrap(); // Status nach Restore muss PendingRebuild sein { let check_db = Database::open(&path).unwrap(); let status = check_db .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!( status, MetadataMacStatus::PendingRebuild, "Nach restore_header_backup muss Status PendingRebuild sein (restore_nonce gesetzt)" ); } // Mount ohne --rebuild-mac (und ohne stdin-Interaktion) muss abbrechen let auth = ContainerAuth::Password(Zeroizing::new(password.to_string())); let res = mount_container( &path, 'Z', None, Some(18103), auth, false, false, None, false, false, None, true, false, ) .await; assert!( res.is_err(), "Mount ohne --rebuild-mac muss nach Header-Restore fail-closed abbrechen" ); // Nonce muss weiterhin existieren, MAC noch nicht gesetzt let check_db2 = Database::open(&path).unwrap(); assert_eq!( check_db2 .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(), MetadataMacStatus::PendingRebuild ); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&backup_path); } #[tokio::test] async fn test_f02_restore_header_with_flag_succeeds() { let path = temp_container_path("f02_restore_flag"); let backup_path = path.with_extension("hdr"); let password = "TestPasswordF02_3!"; let salt = generate_salt(); let kdf = test_kdf_params(); let kek = derive_kek(password, &salt, &kdf).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&path).unwrap(); db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap(); db.set_active_slot_and_dek(0, dek.clone()); let _ = db.create_node(1, "restored_payload.pdf", false).unwrap(); db.update_metadata_mac().unwrap(); db.checkpoint().unwrap(); drop(db); export_header_backup(&path, &backup_path).unwrap(); { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute("DELETE FROM meta", []).unwrap(); } restore_header_backup(&path, &backup_path).unwrap(); // Mount mit rebuild_mac = true let auth = ContainerAuth::Password(Zeroizing::new(password.to_string())); let c_path = path.clone(); let mount_task = tokio::spawn(async move { mount_container( &c_path, 'Y', None, Some(18104), auth, false, false, None, false, false, None, true, true, ) .await }); tokio::time::sleep(Duration::from_millis(250)).await; mount_task.abort(); // Nach Mount mit Flag: MAC ist Valid, restore_nonce gelöscht! let check_db = Database::open(&path).unwrap(); let status = check_db .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!( status, MetadataMacStatus::Valid, "Nach Mount mit --rebuild-mac muss Status Valid sein" ); assert!(check_db.verify_metadata_mac_for_slot(0, &dek).unwrap()); // Prüfen, ob restore_nonce gelöscht wurde let conn = check_db.conn(); let nonce_opt: Option>> = conn .query_row( "SELECT restore_nonce FROM meta WHERE slot_id = 0", [], |r| r.get(0), ) .ok(); assert!( nonce_opt.flatten().is_none(), "restore_nonce muss nach erfolgreichem Rebuild gelöscht sein" ); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&backup_path); } // ----------------------------------------------------------------------------- // F-01 TESTS: Chunk-Generation-Replay im Transcript & Migration // ----------------------------------------------------------------------------- #[tokio::test] async fn test_f01_chunk_generation_replay_attack() { let path = temp_container_path("f01_replay_attack"); let password = "TestPasswordF01!"; let salt = generate_salt(); let kdf = test_kdf_params(); let kek = derive_kek(password, &salt, &kdf).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&path).unwrap(); db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap(); db.set_active_slot_and_dek(0, dek.clone()); // 1. Datei schreiben mit Zustand 1 (Generation 1, z. B. 34 Bytes) let file = db.create_node(1, "financial_report.txt", false).unwrap(); let data_v1 = b"State 1: Balance is 1000 EUR."; let gen1 = db.next_chunk_generation(file.id, 0).unwrap(); assert_eq!(gen1, 1); let (ct1, nonce1, tag1) = encrypt_chunk(&dek, file.id, 0, data_v1, FORMAT_VERSION_V3, gen1).unwrap(); db.write_chunk_and_update_size( file.id, 0, gen1, &nonce1, &tag1, &ct1, data_v1.len() as u64, 1000, ) .unwrap(); db.checkpoint().unwrap(); // Gespeicherte Zeile aus chunks sichern let chunk_v1 = db.read_chunk(file.id, 0).unwrap().unwrap(); assert_eq!(chunk_v1.generation, 1); assert_eq!( db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(), MetadataMacStatus::Valid ); // 2. Datei mit exakt gleicher Größe überschreiben mit Zustand 2 (Generation 2, selbe Länge) let data_v2 = b"State 2: Balance is 9999 EUR."; assert_eq!(data_v1.len(), data_v2.len()); let gen2 = db.next_chunk_generation(file.id, 0).unwrap(); assert_eq!(gen2, 2); let (ct2, nonce2, tag2) = encrypt_chunk(&dek, file.id, 0, data_v2, FORMAT_VERSION_V3, gen2).unwrap(); db.write_chunk_and_update_size( file.id, 0, gen2, &nonce2, &tag2, &ct2, data_v2.len() as u64, 2000, ) .unwrap(); db.checkpoint().unwrap(); assert_eq!( db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(), MetadataMacStatus::Valid ); drop(db); // 3. Angriff: Angreifer restauriert alte Chunk-Zeile (inkl. generation = 1) in SQLite // Größe und chunk_count des Knotens sind unverändert! { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = ?4 WHERE node_id = ?5 AND chunk_index = 0", rusqlite::params![ chunk_v1.nonce.as_slice(), chunk_v1.tag.as_slice(), chunk_v1.ciphertext, chunk_v1.generation, file.id, ], ) .unwrap(); } // 4. Verifikation des Replay-Schutzes (F-01): let attack_db = Database::open(&path).unwrap(); // Hinweis: Die AEAD-Entschlüsselung für sich allein würde hier gelingen, // da die Zeile generation=1 enthält. ABER das Metadaten-Transcript bindet die Generation! let replayed = attack_db.read_chunk(file.id, 0).unwrap().unwrap(); let aead_decrypt = decrypt_chunk( &dek, file.id, 0, &replayed.ciphertext, &replayed.nonce, &replayed.tag, FORMAT_VERSION_V3, replayed.generation, ); assert!( aead_decrypt.is_ok(), "AEAD mit replayed Zeile gelingt, weil generation in der gleichen Zeile liegt" ); // Genau deshalb MUSS der Metadaten-MAC den Replay-Angriff stoppen: let mac_status = attack_db .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!( mac_status, MetadataMacStatus::Invalid, "F-01: Metadaten-MAC MUSS den Replay-Angriff durch abweichende Generation abwehren!" ); assert!( !attack_db.verify_metadata_mac_for_slot(0, &dek).unwrap(), "verify_metadata_mac_for_slot muss false liefern" ); drop(attack_db); // 5. Mount bricht fail-closed ab, kein alter Klartext wird offengelegt let auth = ContainerAuth::Password(Zeroizing::new(password.to_string())); let res = mount_container( &path, 'Z', None, Some(18105), auth, false, false, None, false, false, None, true, false, ) .await; assert!( res.is_err(), "Mount muss bei manipuliertem Chunk-Replay fail-closed abbrechen" ); let _ = std::fs::remove_file(&path); } #[tokio::test] async fn test_f01_migration_existing_v3_container() { let path = temp_container_path("f01_migration"); let password = "TestPasswordF01_Mig!"; let salt = generate_salt(); let kdf = test_kdf_params(); let kek = derive_kek(password, &salt, &kdf).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&path).unwrap(); db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap(); db.set_active_slot_and_dek(0, dek.clone()); let file = db.create_node(1, "legacy_v3_doc.txt", false).unwrap(); let data = b"V3 Legacy Container Content"; let gen = db.next_chunk_generation(file.id, 0).unwrap(); let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, data, FORMAT_VERSION_V3, gen).unwrap(); db.write_chunk_and_update_size(file.id, 0, gen, &n, &t, &ct, data.len() as u64, 1000) .unwrap(); // Simuliere v0.9.3 Zustand: Metadaten-MAC wurde NUR über Knoten berechnet (ohne Chunks) let legacy_canonical = db.canonical_nodes_bytes_for_vault_legacy(0).unwrap(); let mac_key = sanctum::crypto::derive_metadata_mac_key(&dek); let legacy_mac = sanctum::crypto::compute_metadata_mac(&mac_key, 1, &legacy_canonical); { let conn = db.conn(); conn.execute( "UPDATE meta SET metadata_mac = ?1, metadata_gen = 1 WHERE slot_id = 0", rusqlite::params![legacy_mac.as_slice()], ) .unwrap(); } db.checkpoint().unwrap(); // Status muss LegacyValid sein let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(); assert_eq!( status_before, MetadataMacStatus::LegacyValid, "Alter V3-Container muss als LegacyValid erkannt werden" ); assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap()); drop(db); // Mounten migriert transparent auf neues Transcript let auth = ContainerAuth::Password(Zeroizing::new(password.to_string())); let c_path = path.clone(); let mount_task = tokio::spawn(async move { mount_container( &c_path, 'Y', None, Some(18106), auth, false, false, None, false, false, None, true, false, ) .await }); tokio::time::sleep(Duration::from_millis(250)).await; mount_task.abort(); // Nach Mount: Status muss nun Valid unter neuem Transcript sein! let db_after = Database::open(&path).unwrap(); let status_after = db_after .verify_metadata_mac_status_for_slot(0, &dek) .unwrap(); assert_eq!( status_after, MetadataMacStatus::Valid, "Nach Mount muss der MAC auf das neue Format-V3.1 Transcript migriert sein" ); let _ = std::fs::remove_file(&path); } // ----------------------------------------------------------------------------- // F-03 TESTS: upgrade_to_v3 Atomarität & Konsistenz // ----------------------------------------------------------------------------- #[test] fn test_f03_v2_upgrade_single_file_success() { let path = temp_container_path("f03_single_success"); let salt = generate_salt(); let kdf = test_kdf_params(); let dek = generate_dek(); // Erstelle manuell einen V2-Container { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE meta ( slot_id INTEGER 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 kek = derive_kek("TestV2!", &salt, &kdf).unwrap(); let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap(); let params_json = serde_json::to_string(&kdf).unwrap(); conn.execute( "INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ MAGIC_BYTES.as_slice(), salt.as_slice(), params_json, wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], ) .unwrap(); conn.execute( "INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ MAGIC_BYTES.as_slice(), salt.as_slice(), params_json, wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], ) .unwrap(); conn.execute( "INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'file.txt', 0, 12, 100, 100, 0)", [], ) .unwrap(); // Chunk in V2 verschlüsseln (16-Byte AAD, generation wird ignoriert) let plaintext = b"Hello V2 Upgrade!"; let (ct, n, t) = encrypt_chunk(&dek, 3, 0, plaintext, FORMAT_VERSION_V2, 0).unwrap(); conn.execute( "INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)", rusqlite::params![n.as_slice(), t.as_slice(), ct], ) .unwrap(); } let db = Database::open(&path).unwrap(); let meta_before = db.read_meta().unwrap(); assert_eq!(meta_before.version, 2); // Upgrade ausführen db.upgrade_to_v3(&dek).unwrap(); db.checkpoint().unwrap(); // 1. Beide Slots in meta müssen version == 3 haben! let conn = db.conn(); let v0: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| { r.get(0) }) .unwrap(); let v1: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| { r.get(0) }) .unwrap(); assert_eq!(v0, 3, "Slot 0 version muss 3 sein"); assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)"); drop(conn); // 2. Chunks müssen generation = 0 haben und mit V3 24-Byte AAD entschlüsselbar sein let chunk = db.read_chunk(3, 0).unwrap().unwrap(); assert_eq!(chunk.generation, 0); let decrypted = decrypt_chunk( &dek, 3, 0, &chunk.ciphertext, &chunk.nonce, &chunk.tag, FORMAT_VERSION_V3, chunk.generation, ) .unwrap(); assert_eq!(decrypted, b"Hello V2 Upgrade!"); // 3. Metadaten-MAC muss Valid sein assert_eq!( db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(), MetadataMacStatus::Valid ); let _ = std::fs::remove_file(&path); } #[test] fn test_f03_v2_upgrade_corrupted_chunk_rollback() { let path = temp_container_path("f03_rollback"); let salt = generate_salt(); let kdf = test_kdf_params(); let dek = generate_dek(); { let conn = rusqlite::Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TABLE meta ( slot_id INTEGER 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 kek = derive_kek("TestV2!", &salt, &kdf).unwrap(); let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap(); let params_json = serde_json::to_string(&kdf).unwrap(); conn.execute( "INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ MAGIC_BYTES.as_slice(), salt.as_slice(), params_json, wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], ) .unwrap(); conn.execute( "INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ MAGIC_BYTES.as_slice(), salt.as_slice(), params_json, wrapped, h_nonce.as_slice(), h_tag.as_slice(), ], ) .unwrap(); conn.execute( "INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'corrupt.txt', 0, 100, 100, 100, 0)", [], ) .unwrap(); // Gültiger Chunk 0 let (ct0, n0, t0) = encrypt_chunk(&dek, 3, 0, b"Valid chunk 0", FORMAT_VERSION_V2, 0).unwrap(); conn.execute( "INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)", rusqlite::params![n0.as_slice(), t0.as_slice(), ct0], ) .unwrap(); // Beschädigter Chunk 1 (manipulierter Tag) let (ct1, n1, mut t1) = encrypt_chunk(&dek, 3, 1, b"Valid chunk 1", FORMAT_VERSION_V2, 0).unwrap(); t1[0] ^= 0xff; // Tag korrumpieren conn.execute( "INSERT INTO chunks VALUES (3, 1, ?1, ?2, ?3)", rusqlite::params![n1.as_slice(), t1.as_slice(), ct1], ) .unwrap(); } let db = Database::open(&path).unwrap(); // Upgrade MUSS abbrechen let res = db.upgrade_to_v3(&dek); assert!( res.is_err(), "Upgrade muss bei beschädigtem Chunk abbrechen" ); let err_msg = res.unwrap_err().to_string(); assert!( err_msg.contains("Node 3") && err_msg.contains("Index 1"), "Fehler muss Node und Index benennen: {err_msg}" ); // Rollback-Verifikation: let conn = db.conn(); let v0: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| { r.get(0) }) .unwrap(); let v1: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| { r.get(0) }) .unwrap(); assert_eq!(v0, 2, "Nach Rollback muss Version 2 bleiben"); assert_eq!(v1, 2, "Nach Rollback muss Version 2 bleiben"); // Chunk 0 darf nicht umverschlüsselt zurückgeblieben sein (muss weiterhin mit V2 entschlüsselbar sein) drop(conn); let chunk0 = db.read_chunk(3, 0).unwrap().unwrap(); let dec0 = decrypt_chunk( &dek, 3, 0, &chunk0.ciphertext, &chunk0.nonce, &chunk0.tag, FORMAT_VERSION_V2, 0, ); assert!( dec0.is_ok(), "Chunk 0 muss unverändert im V2-Zustand geblieben sein" ); let _ = std::fs::remove_file(&path); } #[tokio::test] async fn test_f03_dual_vault_v2_upgrade_carrier() { let path = temp_container_path("f03_dual_carrier"); let pass_decoy = "DecoyPassword2026!"; let pass_hidden = "HiddenPassword2026!"; let kdf = test_kdf_params(); let salt_0 = generate_salt(); let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf).unwrap(); let dek_0 = generate_dek(); let salt_1 = generate_salt(); let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf).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 carrier_size: u64 = 10 * 1024 * 1024; // 10 MB let db = Database::open(&path).unwrap(); db.init_schema_with_carrier( &salt_0, &kdf, &wrapped_0, &nonce_0, &tag_0, Some(( "carrier.dat", carrier_size, &salt_1, &kdf, &wrapped_1, &nonce_1, &tag_1, &dek_0, &dek_1, )), ) .unwrap(); // Simuliere V2-Zustand für beide Slots und Chunks { let conn = db.conn(); conn.execute("UPDATE meta SET version = 2", []).unwrap(); // Verschlüssele Carrier-Chunks mit V2 (16-Byte AAD) um let mut stmt = conn .prepare("SELECT chunk_index, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1") .unwrap(); let chunks: Vec<(u32, [u8; 12], [u8; 16], Vec)> = stmt .query_map([carrier_node_id], |r| { let idx: u32 = r.get(0)?; let n: Vec = r.get(1)?; let t: Vec = r.get(2)?; let c: Vec = r.get(3)?; let mut n_arr = [0u8; 12]; let mut t_arr = [0u8; 16]; n_arr.copy_from_slice(&n); t_arr.copy_from_slice(&t); Ok((idx, n_arr, t_arr, c)) }) .unwrap() .collect::, _>>() .unwrap(); drop(stmt); for (idx, n, t, c) in chunks { let pt = decrypt_chunk( &dek_0, carrier_node_id, idx, &c, &n, &t, FORMAT_VERSION_V3, 0, ) .unwrap(); let (v2_ct, v2_n, v2_t) = encrypt_chunk(&dek_0, carrier_node_id, idx, &pt, FORMAT_VERSION_V2, 0).unwrap(); conn.execute( "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", rusqlite::params![v2_n.as_slice(), v2_t.as_slice(), v2_ct, carrier_node_id, idx], ) .unwrap(); } } db.checkpoint().unwrap(); // Verifiziere V2-Ausgangszustand let meta_v2 = db.read_meta().unwrap(); assert_eq!(meta_v2.version, 2); // Upgrade mit Decoy-Passwort (DEK_0) durchführen db.upgrade_to_v3(&dek_0).unwrap(); db.checkpoint().unwrap(); // Prüfen: Beide Slots müssen version == 3 sein! { let conn = db.conn(); let v0: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| { r.get(0) }) .unwrap(); let v1: u32 = conn .query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| { r.get(0) }) .unwrap(); assert_eq!(v0, 3, "Slot 0 version muss 3 sein"); assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)"); } drop(db); // Hidden-Mount mit pass_hidden ausführen: Muss Carrier-Chunks fehlerfrei unter DEK_0 mit Version 3 lesen let auth_hidden = ContainerAuth::Password(Zeroizing::new(pass_hidden.to_string())); let c_path = path.clone(); let mount_task = tokio::spawn(async move { mount_container( &c_path, 'Y', None, Some(18107), auth_hidden, false, false, None, false, false, None, true, false, ) .await }); tokio::time::sleep(Duration::from_millis(300)).await; mount_task.abort(); let _ = std::fs::remove_file(&path); }