753 lines
30 KiB
Rust
753 lines
30 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())
|
|
}
|
|
|
|
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
|
|
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme.
|
|
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
|
let cleaned = phrase
|
|
.split_whitespace()
|
|
.collect::<Vec<&str>>()
|
|
.join(" ");
|
|
|
|
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
|
|
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
|
|
|
|
let entropy = mnemonic.to_entropy();
|
|
if entropy.len() != 32 {
|
|
bail!(
|
|
"Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}",
|
|
entropy.len()
|
|
);
|
|
}
|
|
|
|
let mut dek = Zeroizing::new([0u8; 32]);
|
|
dek.copy_from_slice(&entropy);
|
|
Ok(dek)
|
|
}
|
|
|
|
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
|
|
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
|
#[inline]
|
|
pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
|
|
let mut aad = [0u8; 16];
|
|
aad[..8].copy_from_slice(&node_id.to_le_bytes());
|
|
aad[8..].copy_from_slice(&(chunk_index as u64).to_le_bytes());
|
|
aad
|
|
}
|
|
|
|
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
|
|
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
|
|
/// sofern dadurch eine Größenreduktion erzielt wird.
|
|
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
|
|
pub fn encrypt_chunk(
|
|
dek: &[u8; 32],
|
|
node_id: i64,
|
|
chunk_index: u32,
|
|
plaintext: &[u8],
|
|
format_version: u32,
|
|
) -> Result<(Vec<u8>, [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, ¶ms).unwrap();
|
|
let dek = generate_dek();
|
|
|
|
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
assert_eq!(wrapped.len(), 32);
|
|
|
|
// Erfolgreiche Entschlüsselung
|
|
let unwrapped = unwrap_dek(&kek, &wrapped, &nonce, &tag).unwrap();
|
|
assert_eq!(*dek, *unwrapped);
|
|
|
|
// Falscher KEK schlägt fehl
|
|
let wrong_kek = derive_kek("WrongPassword!", &salt, ¶ms).unwrap();
|
|
assert!(unwrap_dek(&wrong_kek, &wrapped, &nonce, &tag).is_err());
|
|
|
|
// Manipulierter Tag schlägt fehl
|
|
let mut tampered_tag = tag;
|
|
tampered_tag[0] ^= 0xFF;
|
|
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_chunk_encryption_and_swap_protection() {
|
|
let dek = generate_dek();
|
|
let plaintext = b"Hello, Sanctum Encrypted Storage World!";
|
|
let node_id = 42i64;
|
|
let chunk_index = 0u32;
|
|
|
|
let (ciphertext, nonce, tag) =
|
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
|
|
|
// Reguläre Entschlüsselung (v2)
|
|
let decrypted =
|
|
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
|
|
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
|
let swap_node_err =
|
|
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
|
assert!(swap_node_err.is_err());
|
|
|
|
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
|
let swap_idx_err =
|
|
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
|
assert!(swap_idx_err.is_err());
|
|
|
|
// Manipulation des Ciphertexts
|
|
let mut tampered_ct = ciphertext.clone();
|
|
tampered_ct[0] ^= 0x01;
|
|
assert!(
|
|
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lz4_chunk_compression_efficiency() {
|
|
let dek = generate_dek();
|
|
// Stark komprimierbarer Text (z.B. Logdatei, JSON, Quellcode)
|
|
let repeated_text = "Sanctum Secure Vault Storage System ".repeat(500);
|
|
let plaintext = repeated_text.as_bytes();
|
|
let node_id = 10i64;
|
|
let chunk_index = 0u32;
|
|
|
|
let (ciphertext, nonce, tag) =
|
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
|
|
|
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
|
|
assert!(
|
|
ciphertext.len() < plaintext.len() / 5,
|
|
"Ciphertext ({}) sollte drastisch kleiner als Plaintext ({}) sein",
|
|
ciphertext.len(),
|
|
plaintext.len()
|
|
);
|
|
|
|
let decrypted =
|
|
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lz4_chunk_compression_threshold() {
|
|
let dek = generate_dek();
|
|
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
|
|
let mut random_bytes = vec![0u8; 1000];
|
|
OsRng.fill_bytes(&mut random_bytes);
|
|
|
|
let (ct, nonce, tag) =
|
|
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
|
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
|
assert_eq!(ct.len(), random_bytes.len() + 1);
|
|
|
|
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
|
assert_eq!(decrypted, random_bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn test_v1_backward_compatibility() {
|
|
let dek = generate_dek();
|
|
let plaintext = b"Uncompressed Legacy V1 Chunk Payload";
|
|
let node_id = 5i64;
|
|
let chunk_index = 0u32;
|
|
|
|
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
|
let (ciphertext, nonce, tag) =
|
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
|
|
assert_eq!(ciphertext.len(), plaintext.len());
|
|
|
|
let decrypted =
|
|
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bip39_recovery_phrase_roundtrip() {
|
|
let dek = generate_dek();
|
|
let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic");
|
|
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
|
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
|
|
|
|
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
|
|
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
|
|
|
|
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
|
|
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
|
|
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
|
|
assert_eq!(*dek, *recovered_messy);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bip39_invalid_words_and_checksum() {
|
|
// 1. Nicht im Wörterbuch enthaltenes Wort
|
|
let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword";
|
|
assert!(mnemonic_to_dek(invalid_word_phrase).is_err());
|
|
|
|
// 2. Falsche Wortanzahl (z. B. 23 statt 24)
|
|
let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow";
|
|
assert!(mnemonic_to_dek(short_phrase).is_err());
|
|
|
|
// 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert)
|
|
let dek = generate_dek();
|
|
let mut words: Vec<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_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);
|
|
}
|
|
}
|
|
|
|
|