Files
sanctum/src/crypto.rs
T
harald 541190cff4 feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
2026-09-10 13:27:01 +02:00

883 lines
34 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 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<Zeroizing<[u8; 32]>> {
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>, [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). Dadurch sind Standard-Container von
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
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
}
/// 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<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 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<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)
}
/// 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>, [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<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 = 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, &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_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<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)
);
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);
}
}