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 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_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 pub const COMPRESSION_NONE: u8 = 0x00; pub const COMPRESSION_LZ4: u8 = 0x01; pub const DEFAULT_MEMORY_COST_KIB: u32 = 256 * 1024; // 256 MB (262_144 KiB) pub const DEFAULT_TIME_COST: u32 = 4; 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, } } } pub const MIN_MEMORY_COST_KIB: u32 = 19_456; pub const MAX_MEMORY_COST_KIB: u32 = 262_144; pub const MIN_TIME_COST: u32 = 2; pub const MAX_TIME_COST: u32 = 8; pub const MIN_PARALLELISM: u32 = 1; pub const MAX_PARALLELISM: u32 = 8; pub const MIN_PASSWORD_LENGTH: usize = 12; /// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04). pub fn validate_kdf_params(params: &KdfParams) -> Result<()> { if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(¶ms.memory_cost) { bail!( "KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)", params.memory_cost, MIN_MEMORY_COST_KIB, MAX_MEMORY_COST_KIB ); } if !(MIN_TIME_COST..=MAX_TIME_COST).contains(¶ms.time_cost) { bail!( "KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})", params.time_cost, MIN_TIME_COST, MAX_TIME_COST ); } if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(¶ms.parallelism) { bail!( "KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})", params.parallelism, MIN_PARALLELISM, MAX_PARALLELISM ); } Ok(()) } /// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10). pub fn validate_password(password: &str) -> Result<()> { if password.chars().count() < MIN_PASSWORD_LENGTH { bail!( "Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).", MIN_PASSWORD_LENGTH, password.chars().count() ); } Ok(()) } /// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist. pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> { if pass0 == pass1 { bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!"); } else if pass0.starts_with(pass1) || pass1.starts_with(pass0) { bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit."); } else { Ok(()) } } /// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab. /// RAII-Guard für kurzzeitige Stack-Puffer, um Lock-Leaks bei Fehlern oder Rückgabe zu verhindern (V-03). struct ScopedMemoryLock(*const u8, usize); impl Drop for ScopedMemoryLock { fn drop(&mut self) { crate::windows::unlock_memory(self.0, self.1); } } pub fn derive_kek( password: &str, salt: &[u8; 16], params: &KdfParams, ) -> Result> { validate_kdf_params(params)?; 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]); let _ = crate::windows::lock_memory(kek.as_ptr(), 32); let _lock_guard = ScopedMemoryLock(kek.as_ptr(), 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]); let _ = crate::windows::lock_memory(dek.as_ptr(), 32); let _lock_guard = ScopedMemoryLock(dek.as_ptr(), 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) für Dummy-Slot-Längenparität im Header. 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 } static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10). pub fn set_allow_legacy_names(allow: bool) { ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed); } pub fn allows_legacy_names() -> bool { ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed) } /// 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 = match Aes256Gcm::new_from_slice(dek) { Ok(c) => c, Err(_) => return String::new(), }; let mut buffer = name.as_bytes().to_vec(); let aad = build_name_aad(parent_id); let tag = match cipher.encrypt_in_place_detached( Nonce::from_slice(&nonce_bytes), &aad, &mut buffer, ) { Ok(t) => t, Err(_) => return String::new(), }; 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 optional Fallback /// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10). pub fn decrypt_node_name_ext( dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool, ) -> 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 nur wenn explizit aktiviert (S-10) if allow_legacy { 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 nur wenn explizit aktiviert (S-10) if allow_legacy { 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 } pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option { decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names()) } /// 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()) } pub fn levenshtein_distance(a: &str, b: &str) -> usize { let a_chars: Vec = a.chars().collect(); let b_chars: Vec = b.chars().collect(); let (m, n) = (a_chars.len(), b_chars.len()); let mut dp = vec![vec![0usize; n + 1]; m + 1]; for i in 0..=m { dp[i][0] = i; } for j in 0..=n { dp[0][j] = j; } for i in 1..=m { for j in 1..=n { let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; dp[i][j] = (dp[i - 1][j] + 1) .min(dp[i][j - 1] + 1) .min(dp[i - 1][j - 1] + cost); } } dp[m][n] } /// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort. pub fn suggest_bip39_word(word: &str) -> Option<&'static str> { let word_list = bip39::Language::English.word_list(); let mut best_match = None; let mut min_dist = usize::MAX; for &valid in word_list { let dist = levenshtein_distance(word, valid); if dist < min_dist { min_dist = dist; best_match = Some(valid); } } // Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2) if min_dist <= 2 { best_match } else { None } } /// Normalisiert eine eingegebene BIP-39 Phrase: /// - Wandelt in Kleinbuchstaben um (to_lowercase) /// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen /// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing) pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec { let cleaned: String = phrase .chars() .map(|c| { if c.is_alphabetic() { c.to_ascii_lowercase() } else { ' ' } }) .collect(); cleaned.split_whitespace().map(|s| s.to_string()).collect() } /// 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 mit präziser Fehlerdiagnose. pub fn mnemonic_to_dek(phrase: &str) -> Result> { let words = normalize_mnemonic_phrase(phrase); if words.is_empty() { bail!("Die Notfallphrase darf nicht leer sein."); } if words.len() != 24 { bail!( "Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.", words.len() ); } let word_list = bip39::Language::English.word_list(); let mut invalid_words = Vec::new(); for (idx, word) in words.iter().enumerate() { if !word_list.contains(&word.as_str()) { let suggestion = suggest_bip39_word(word); let msg = if let Some(sug) = suggestion { format!( "Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug ) } else { format!( "Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word ) }; invalid_words.push(msg); } } if !invalid_words.is_empty() { bail!( "Ungültige Wörter in der Notfallphrase festgestellt:\n • {}", invalid_words.join("\n • ") ); } let normalized_phrase = words.join(" "); let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase) .map_err(|_| { anyhow::anyhow!( "Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \ Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort." ) })?; 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) } /// 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] 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 } /// Erzeugt die 24-Byte Associated Data (AAD) für einen Chunk in Format V3 (K-02 Chunk-Replay-Schutz): /// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian) || generation (8 Bytes Little-Endian). #[inline] pub fn build_chunk_aad_v3(node_id: i64, chunk_index: u32, generation: u64) -> [u8; 24] { let mut aad = [0u8; 24]; aad[..8].copy_from_slice(&node_id.to_le_bytes()); aad[8..16].copy_from_slice(&(chunk_index as u64).to_le_bytes()); aad[16..24].copy_from_slice(&generation.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. /// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers verwendet (K-02). /// 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, generation: u64, ) -> 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_16; let aad_24; let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 { aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation); &aad_24 } else { aad_16 = build_chunk_aad(node_id, chunk_index); &aad_16 }; 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). /// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers geprüft (K-02). 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, generation: u64, ) -> 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_16; let aad_24; let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 { aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation); &aad_24 } else { aad_16 = build_chunk_aad(node_id, chunk_index); &aad_16 }; 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 mut size_bytes = [0u8; 4]; size_bytes.copy_from_slice(&payload[0..4]); let uncompressed_size = u32::from_le_bytes(size_bytes) 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: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, 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_m01_default_kdf_params_256mib_4_iterations() { let defaults = KdfParams::default(); assert_eq!( defaults.memory_cost, 256 * 1024, "Default memory cost must be 256 MiB (262,144 KiB)" ); assert_eq!( defaults.time_cost, 4, "Default time cost must be 4 iterations" ); assert_eq!( defaults.parallelism, 4, "Default parallelism must be 4 threads" ); assert!( validate_kdf_params(&defaults).is_ok(), "Default KDF parameters must pass validation" ); } #[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, 0).unwrap(); // Reguläre Entschlüsselung (v2) let decrypted = decrypt_chunk( &dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2, 0, ) .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, 0, ); 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, 0, ); 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, 0, ) .is_err()); } #[test] fn test_k02_chunk_replay_protection_with_generation_aad() { let dek = generate_dek(); let plaintext_v1 = b"Original Chunk Data at Generation 1"; let plaintext_v2 = b"Overwritten Chunk Data at Generation 2"; let node_id = 42i64; let chunk_index = 0u32; // 1. Chunk mit Generation 1 verschlüsseln let (ct1, nonce1, tag1) = encrypt_chunk( &dek, node_id, chunk_index, plaintext_v1, FORMAT_VERSION_V3, 1, ) .unwrap(); // Verifiziere reguläre Entschlüsselung mit Generation 1 let dec1 = decrypt_chunk( &dek, node_id, chunk_index, &ct1, &nonce1, &tag1, FORMAT_VERSION_V3, 1, ) .unwrap(); assert_eq!(dec1, plaintext_v1); // 2. Replay-Schutz: Entschlüsselung mit falscher Generation (z. B. 2) MUSS scheitern! let replay_err = decrypt_chunk( &dek, node_id, chunk_index, &ct1, &nonce1, &tag1, FORMAT_VERSION_V3, 2, ); assert!( replay_err.is_err(), "Ciphertext von Gen 1 darf unter Gen 2 AAD nicht entschlüsselt werden" ); // 3. Chunk überschreiben mit Generation 2 let (ct2, nonce2, tag2) = encrypt_chunk( &dek, node_id, chunk_index, plaintext_v2, FORMAT_VERSION_V3, 2, ) .unwrap(); let dec2 = decrypt_chunk( &dek, node_id, chunk_index, &ct2, &nonce2, &tag2, FORMAT_VERSION_V3, 2, ) .unwrap(); assert_eq!(dec2, plaintext_v2); // 4. Replay-Angriff: Angreifer spielt ct1 (Gen 1) ein, während System Gen 2 erwartet let attack_res = decrypt_chunk( &dek, node_id, chunk_index, &ct1, &nonce1, &tag1, FORMAT_VERSION_V3, 2, ); assert!( attack_res.is_err(), "Replay von altem Ciphertext muss abgewehrt werden" ); // 5. Abwärtskompatibilität: In V2 wird generation ignoriert let (ct_v2, nonce_v2, tag_v2) = encrypt_chunk( &dek, node_id, chunk_index, plaintext_v1, FORMAT_VERSION_V2, 0, ) .unwrap(); let dec_v2 = decrypt_chunk( &dek, node_id, chunk_index, &ct_v2, &nonce_v2, &tag_v2, FORMAT_VERSION_V2, 999, // beliebig ) .unwrap(); assert_eq!(dec_v2, plaintext_v1); } #[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, 0).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, 0, ) .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, 0).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, 0).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, 0).unwrap(); assert_eq!(ciphertext.len(), plaintext.len()); let decrypted = decrypt_chunk( &dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1, 0, ) .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_bip39_advanced_normalisation_and_suggestions() { let dek = generate_dek(); let mnemonic_str = dek_to_mnemonic(&dek).unwrap(); let words: Vec<&str> = mnemonic_str.split_whitespace().collect(); // 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization) let uppercase_phrase = mnemonic_str.to_uppercase(); let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase"); assert_eq!(*dek, *recovered); // 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern) let punctuated = format!("{}.", words.join(", ")); let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated"); assert_eq!(*dek, *recovered_punct); // 3. Typo-Vorschlag (Levenshtein) let mut typo_words = words.clone(); typo_words[5] = "abondon"; // Typo for abandon let typo_phrase = typo_words.join(" "); let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string(); assert!(err.contains("Wort #6 'abondon' ist ungültig")); assert!(err.contains("Meinten Sie 'abandon'?")); } #[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) ); // Standardmäßig muss Legacy-AAD abgewiesen werden (S-07) assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none()); // Mit aktiviertem Legacy-Flag darf es entschlüsselt werden set_allow_legacy_names(true); let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format) .expect("Decrypt legacy static AAD name with flag"); assert_eq!(decrypted_static, filename); set_allow_legacy_names(false); // 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 test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1, }; let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap(); let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).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, 0, ); 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 ); } #[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)); } }