use aes_gcm::{ aead::{AeadInPlace, KeyInit}, Aes256Gcm, Nonce, Tag, }; use anyhow::{bail, Result}; use argon2::{Algorithm, Argon2, Params, Version}; use rand::rngs::OsRng; use rand::RngCore; use serde::{Deserialize, Serialize}; 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 CHUNK_SIZE: usize = 1024 * 1024; // 1 MB /// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2 pub const COMPRESSION_NONE: u8 = 0x00; pub const COMPRESSION_LZ4: u8 = 0x01; pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB pub const DEFAULT_TIME_COST: u32 = 3; pub const DEFAULT_PARALLELISM: u32 = 4; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct KdfParams { pub memory_cost: u32, pub time_cost: u32, pub parallelism: u32, } impl Default for KdfParams { fn default() -> Self { Self { memory_cost: DEFAULT_MEMORY_COST_KIB, time_cost: DEFAULT_TIME_COST, parallelism: DEFAULT_PARALLELISM, } } } /// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab. pub fn derive_kek( password: &str, salt: &[u8], params: &KdfParams, ) -> Result> { let argon2_params = Params::new( params.memory_cost, params.time_cost, params.parallelism, Some(32), ) .map_err(|e| anyhow::anyhow!("Ungültige Argon2-Parameter: {e}"))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); let mut kek = Zeroizing::new([0u8; 32]); argon2 .hash_password_into(password.as_bytes(), salt, &mut *kek) .map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?; Ok(kek) } /// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK). pub fn generate_dek() -> Zeroizing<[u8; 32]> { let mut dek = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(&mut *dek); dek } /// Generiert ein kryptografisch sicheres 16-Byte KDF-Salt. pub fn generate_salt() -> [u8; 16] { let mut salt = [0u8; 16]; OsRng.fill_bytes(&mut salt); salt } /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM. pub fn wrap_key_payload( kek: &[u8; 32], payload: &[u8], ) -> Result<(Vec, [u8; 12], [u8; 16])> { let cipher = Aes256Gcm::new_from_slice(kek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let mut buffer = payload.to_vec(); let tag = cipher .encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) .map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?; let mut tag_bytes = [0u8; 16]; tag_bytes.copy_from_slice(tag.as_slice()); Ok((buffer, nonce_bytes, tag_bytes)) } /// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität. pub fn unwrap_key_payload( kek: &[u8; 32], wrapped_payload: &[u8], nonce_bytes: &[u8; 12], tag_bytes: &[u8; 16], ) -> Result>> { let cipher = Aes256Gcm::new_from_slice(kek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; let nonce = Nonce::from_slice(nonce_bytes); let tag = Tag::from_slice(tag_bytes); let mut buffer = wrapped_payload.to_vec(); cipher .decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag) .map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?; Ok(Zeroizing::new(buffer)) } /// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM. /// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. pub fn wrap_dek( kek: &[u8; 32], dek: &[u8; 32], ) -> Result<(Vec, [u8; 12], [u8; 16])> { wrap_key_payload(kek, dek) } /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität. pub fn unwrap_dek( kek: &[u8; 32], wrapped_dek: &[u8], nonce_bytes: &[u8; 12], tag_bytes: &[u8; 16], ) -> Result> { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?; if payload.len() < 32 { bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len()); } let mut dek = Zeroizing::new([0u8; 32]); dek.copy_from_slice(&payload[0..32]); Ok(dek) } /// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian). pub fn wrap_slot0_payload( kek: &[u8; 32], dek_0: &[u8; 32], carrier_node_id: i64, ) -> Result<(Vec, [u8; 12], [u8; 16])> { let mut payload = Vec::with_capacity(40); payload.extend_from_slice(dek_0); payload.extend_from_slice(&carrier_node_id.to_le_bytes()); wrap_key_payload(kek, &payload) } /// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian). pub fn wrap_slot1_payload( kek: &[u8; 32], dek_1: &[u8; 32], dek_0: &[u8; 32], carrier_node_id: i64, ) -> Result<(Vec, [u8; 12], [u8; 16])> { let mut payload = Vec::with_capacity(72); payload.extend_from_slice(dek_1); payload.extend_from_slice(dek_0); payload.extend_from_slice(&carrier_node_id.to_le_bytes()); wrap_key_payload(kek, &payload) } /// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie /// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von /// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). pub fn generate_dummy_slot() -> (Vec, [u8; 12], [u8; 16], [u8; 16]) { let mut wrapped_dek = vec![0u8; 72]; let mut nonce = [0u8; 12]; let mut tag = [0u8; 16]; let mut salt = [0u8; 16]; OsRng.fill_bytes(&mut wrapped_dek); OsRng.fill_bytes(&mut nonce); OsRng.fill_bytes(&mut tag); OsRng.fill_bytes(&mut salt); (wrapped_dek, nonce, tag, salt) } /// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault, /// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern: /// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian). #[inline] pub fn build_name_aad(parent_id: i64) -> [u8; 16] { let mut aad = [0u8; 16]; aad[..8].copy_from_slice(b"SANCNAME"); aad[8..].copy_from_slice(&parent_id.to_le_bytes()); aad } /// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein. /// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind /// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können. /// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext). pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String { let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init"); let mut buffer = name.as_bytes().to_vec(); let aad = build_name_aad(parent_id); let tag = cipher .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) .expect("Name encryption"); let mut combined = Vec::with_capacity(12 + 16 + buffer.len()); combined.extend_from_slice(&nonce_bytes); combined.extend_from_slice(tag.as_slice()); combined.extend_from_slice(&buffer); hex::encode(combined) } /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. /// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback /// auf die statische AAD für ältere Container (Abwärtskompatibilität). pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option { // Abwärtskompatibilität für alte v0.2.0 $h$$$ Namen if let Some(rest) = stored.strip_prefix("$h$") { let parts: Vec<&str> = rest.split('$').collect(); if parts.len() == 3 { if let (Ok(nonce_bytes), Ok(tag_bytes), Ok(ct_bytes)) = ( hex::decode(parts[0]), hex::decode(parts[1]), hex::decode(parts[2]), ) { if nonce_bytes.len() == 12 && tag_bytes.len() == 16 { let cipher = Aes256Gcm::new_from_slice(dek).ok()?; // 1. Primär: Authentifizierung mit parent_id AAD let aad = build_name_aad(parent_id); let mut buffer = ct_bytes.clone(); if cipher .decrypt_in_place_detached( Nonce::from_slice(&nonce_bytes), &aad, &mut buffer, Tag::from_slice(&tag_bytes), ) .is_ok() { return String::from_utf8(buffer).ok(); } // 2. Fallback: Statische AAD für echte Legacy-Dateinamen let mut buffer_legacy = ct_bytes; if cipher .decrypt_in_place_detached( Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer_legacy, Tag::from_slice(&tag_bytes), ) .is_ok() { return String::from_utf8(buffer_legacy).ok(); } } } } return None; } // Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext) if stored.len() >= 56 { if let Ok(bytes) = hex::decode(stored) { if bytes.len() >= 28 { let nonce = &bytes[0..12]; let tag = &bytes[12..28]; let ct = &bytes[28..]; if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) { // 1. Primär: Authentifizierung mit parent_id AAD let aad = build_name_aad(parent_id); let mut buffer = ct.to_vec(); if cipher .decrypt_in_place_detached( Nonce::from_slice(nonce), &aad, &mut buffer, Tag::from_slice(tag), ) .is_ok() { return String::from_utf8(buffer).ok(); } // 2. Fallback: Alte statische AAD für bestehende Container let mut buffer_legacy = ct.to_vec(); if cipher .decrypt_in_place_detached( Nonce::from_slice(nonce), b"SANCTUM_NODE_NAME", &mut buffer_legacy, Tag::from_slice(tag), ) .is_ok() { return String::from_utf8(buffer_legacy).ok(); } } } } } None } /// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme. pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result { let mnemonic = bip39::Mnemonic::from_entropy(dek) .map_err(|e| anyhow::anyhow!("Fehler beim Erzeugen der BIP-39 Notfallphrase: {e}"))?; Ok(mnemonic.to_string()) } /// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. /// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme. pub fn mnemonic_to_dek(phrase: &str) -> Result> { let cleaned = phrase .split_whitespace() .collect::>() .join(" "); let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned) .map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?; let entropy = mnemonic.to_entropy(); if entropy.len() != 32 { bail!( "Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}", entropy.len() ); } let mut dek = Zeroizing::new([0u8; 32]); dek.copy_from_slice(&entropy); Ok(dek) } /// 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] pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] { let mut aad = [0u8; 16]; aad[..8].copy_from_slice(&node_id.to_le_bytes()); aad[8..].copy_from_slice(&(chunk_index as u64).to_le_bytes()); aad } /// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD. /// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert, /// sofern dadurch eine Größenreduktion erzielt wird. /// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück. pub fn encrypt_chunk( dek: &[u8; 32], node_id: i64, chunk_index: u32, plaintext: &[u8], format_version: u32, ) -> Result<(Vec, [u8; 12], [u8; 16])> { let cipher = Aes256Gcm::new_from_slice(dek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let aad = build_chunk_aad(node_id, chunk_index); let mut buffer = if format_version >= FORMAT_VERSION_V2 { if plaintext.is_empty() { vec![COMPRESSION_NONE] } else { let compressed = lz4_flex::compress_prepend_size(plaintext); // Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag) if compressed.len() + 64 <= plaintext.len() { let mut buf = Vec::with_capacity(compressed.len() + 1); buf.push(COMPRESSION_LZ4); buf.extend_from_slice(&compressed); buf } else { let mut buf = Vec::with_capacity(plaintext.len() + 1); buf.push(COMPRESSION_NONE); buf.extend_from_slice(plaintext); buf } } } else { plaintext.to_vec() }; let tag = cipher .encrypt_in_place_detached(nonce, &aad, &mut buffer) .map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?; let mut tag_bytes = [0u8; 16]; tag_bytes.copy_from_slice(tag.as_slice()); Ok((buffer, nonce_bytes, tag_bytes)) } /// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM. /// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2). pub fn decrypt_chunk( dek: &[u8; 32], node_id: i64, chunk_index: u32, ciphertext: &[u8], nonce_bytes: &[u8; 12], tag_bytes: &[u8; 16], format_version: u32, ) -> Result> { let cipher = Aes256Gcm::new_from_slice(dek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; let nonce = Nonce::from_slice(nonce_bytes); let tag = Tag::from_slice(tag_bytes); let aad = build_chunk_aad(node_id, chunk_index); let mut buffer = ciphertext.to_vec(); cipher .decrypt_in_place_detached(nonce, &aad, &mut buffer, tag) .map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?; if format_version >= FORMAT_VERSION_V2 { if buffer.is_empty() { return Ok(Vec::new()); } match buffer[0] { COMPRESSION_NONE => Ok(buffer[1..].to_vec()), COMPRESSION_LZ4 => { let payload = &buffer[1..]; if payload.len() < 4 { bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix"); } let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize; if uncompressed_size > CHUNK_SIZE { bail!( "LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)", uncompressed_size, CHUNK_SIZE ); } let decompressed = lz4_flex::decompress_size_prepended(payload) .map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?; Ok(decompressed) } other => bail!("Unbekannte Chunk-Kompressionsmethode: 0x{:02x}", other), } } else { Ok(buffer) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_kdf_and_dek_wrapping() { let password = "SuperSecretMasterPassword123!"; let salt = generate_salt(); let params = KdfParams { memory_cost: 1024, // Schneller für Unit-Test time_cost: 1, parallelism: 1, }; let kek = derive_kek(password, &salt, ¶ms).unwrap(); let dek = generate_dek(); let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); assert_eq!(wrapped.len(), 32); // Erfolgreiche Entschlüsselung let unwrapped = unwrap_dek(&kek, &wrapped, &nonce, &tag).unwrap(); assert_eq!(*dek, *unwrapped); // Falscher KEK schlägt fehl let wrong_kek = derive_kek("WrongPassword!", &salt, ¶ms).unwrap(); assert!(unwrap_dek(&wrong_kek, &wrapped, &nonce, &tag).is_err()); // Manipulierter Tag schlägt fehl let mut tampered_tag = tag; tampered_tag[0] ^= 0xFF; assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err()); } #[test] fn test_chunk_encryption_and_swap_protection() { let dek = generate_dek(); let plaintext = b"Hello, Sanctum Encrypted Storage World!"; let node_id = 42i64; let chunk_index = 0u32; let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap(); // Reguläre Entschlüsselung (v2) let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); assert_eq!(decrypted, plaintext); // Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben) let swap_node_err = decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2); assert!(swap_node_err.is_err()); // Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben) let swap_idx_err = decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2); assert!(swap_idx_err.is_err()); // Manipulation des Ciphertexts let mut tampered_ct = ciphertext.clone(); tampered_ct[0] ^= 0x01; assert!( decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err() ); } #[test] fn test_lz4_chunk_compression_efficiency() { let dek = generate_dek(); // Stark komprimierbarer Text (z.B. Logdatei, JSON, Quellcode) let repeated_text = "Sanctum Secure Vault Storage System ".repeat(500); let plaintext = repeated_text.as_bytes(); let node_id = 10i64; let chunk_index = 0u32; let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap(); // Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext assert!( ciphertext.len() < plaintext.len() / 5, "Ciphertext ({}) sollte drastisch kleiner als Plaintext ({}) sein", ciphertext.len(), plaintext.len() ); let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn test_lz4_chunk_compression_threshold() { let dek = generate_dek(); // Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis) let mut random_bytes = vec![0u8; 1000]; OsRng.fill_bytes(&mut random_bytes); let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap(); // Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert assert_eq!(ct.len(), random_bytes.len() + 1); let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); assert_eq!(decrypted, random_bytes); } #[test] fn test_v1_backward_compatibility() { let dek = generate_dek(); let plaintext = b"Uncompressed Legacy V1 Chunk Payload"; let node_id = 5i64; let chunk_index = 0u32; // V1 Format: Reine Verschlüsselung ohne Kompressionspräfix let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap(); assert_eq!(ciphertext.len(), plaintext.len()); let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn test_bip39_recovery_phrase_roundtrip() { let dek = generate_dek(); let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic"); let words: Vec<&str> = mnemonic_str.split_whitespace().collect(); assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words"); let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK"); assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK"); // Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche) let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n ")); let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy"); assert_eq!(*dek, *recovered_messy); } #[test] fn test_bip39_invalid_words_and_checksum() { // 1. Nicht im Wörterbuch enthaltenes Wort let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword"; assert!(mnemonic_to_dek(invalid_word_phrase).is_err()); // 2. Falsche Wortanzahl (z. B. 23 statt 24) let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow"; assert!(mnemonic_to_dek(short_phrase).is_err()); // 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert) let dek = generate_dek(); let mut words: Vec = dek_to_mnemonic(&dek) .unwrap() .split_whitespace() .map(|s| s.to_string()) .collect(); // Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort let original_last = words[23].clone(); words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() }; let corrupted_phrase = words.join(" "); assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); } #[test] fn test_hidden_node_name_encryption_and_dummy_slot() { let dek = generate_dek(); let filename = "ultra_geheimes_dokument.pdf"; let parent_id = 2i64; let encrypted = encrypt_node_name(&dek, parent_id, filename); // Kein verräterisches Präfix mehr! Reines Hex. assert!(!encrypted.starts_with("$h$")); assert!(!encrypted.contains(filename)); assert!(encrypted.len() >= 56); let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name"); assert_eq!(decrypted, filename); // Abwärtskompatibilität: Legacy $h$$$ Format muss weiter entschlüsselt werden let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]); let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name"); assert_eq!(decrypted_legacy, filename); // Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME") let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap(); let mut static_buf = filename.as_bytes().to_vec(); let static_nonce = [42u8; 12]; let static_tag = cipher .encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf) .unwrap(); let legacy_static_format = format!( "$h${}${}${}", hex::encode(static_nonce), hex::encode(static_tag), hex::encode(&static_buf) ); let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name"); assert_eq!(decrypted_static, filename); // Mit anderem DEK schlägt Entschlüsselung fehl let other_dek = generate_dek(); assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none()); // Dummy-Slot hat korrekte Längen (72 Bytes für Modell A) let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); assert_eq!(dummy_dek.len(), 72); assert_eq!(dummy_nonce.len(), 12); assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_salt.len(), 16); } #[test] fn test_node_name_aad_parent_binding() { let dek = generate_dek(); let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt"); let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt"); // Gültige parent_ids entschlüsseln erfolgreich assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt"); assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt"); // Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20 assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!"); assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!"); } #[test] fn test_model_a_slot_payloads() { let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap(); let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap(); let dek_0 = generate_dek(); let dek_1 = generate_dek(); let carrier_node_id = 42i64; // Slot 0 Payload: 40 Bytes let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); assert_eq!(wrapped_0.len(), 40); let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap(); assert_eq!(unwrapped_0.len(), 40); assert_eq!(&unwrapped_0[0..32], &*dek_0); let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap()); assert_eq!(recovered_cid_0, carrier_node_id); // Slot 1 Payload: 72 Bytes let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); assert_eq!(wrapped_1.len(), 72); let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap(); assert_eq!(unwrapped_1.len(), 72); assert_eq!(&unwrapped_1[0..32], &*dek_1); assert_eq!(&unwrapped_1[32..64], &*dek_0); let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap()); assert_eq!(recovered_cid_1, carrier_node_id); } #[test] fn test_lz4_decompression_bomb_protection() { use aes_gcm::KeyInit; let dek = generate_dek(); let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap(); let node_id = 999; let chunk_index = 0; let aad = build_chunk_aad(node_id, chunk_index); // Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE) let mut malicious_plaintext = Vec::new(); malicious_plaintext.push(COMPRESSION_LZ4); let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes()); malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload let mut nonce_bytes = [0u8; 12]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let mut ct = malicious_plaintext.clone(); let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap(); let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap(); // Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2); assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"); let err_msg = res.err().unwrap().to_string(); assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg); } }