Files
sanctum/src/crypto.rs
T

1403 lines
48 KiB
Rust

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(&params.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(&params.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(&params.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<Zeroizing<[u8; 32]>> {
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>, [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<Zeroizing<Vec<u8>>> {
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>, [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<Zeroizing<[u8; 32]>> {
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>, [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>, [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>, [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<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> 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<String> {
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<String> {
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<char> = a.chars().collect();
let b_chars: Vec<char> = 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<String> {
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<Zeroizing<[u8; 32]>> {
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>, [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<Vec<u8>> {
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, &params).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, &params).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<String> = 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$<nonce>$<tag>$<ct> 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));
}
}