feat(compression): implement transparent LZ4 chunk compression with V1 backwards compatibility
This commit is contained in:
+106
-9
@@ -10,9 +10,15 @@ use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
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;
|
||||
@@ -134,12 +140,15 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// 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}"))?;
|
||||
@@ -150,7 +159,28 @@ pub fn encrypt_chunk(
|
||||
|
||||
let aad = build_chunk_aad(node_id, chunk_index);
|
||||
|
||||
let mut buffer = plaintext.to_vec();
|
||||
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 tatsächlich Bytes gespart werden (+1 Byte für das Flag)
|
||||
if compressed.len() + 1 < 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}"))?;
|
||||
@@ -162,6 +192,7 @@ pub fn encrypt_chunk(
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -169,6 +200,7 @@ pub fn decrypt_chunk(
|
||||
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}"))?;
|
||||
@@ -182,9 +214,25 @@ pub fn decrypt_chunk(
|
||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
|
||||
|
||||
Ok(buffer)
|
||||
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 decompressed = lz4_flex::decompress_size_prepended(&buffer[1..])
|
||||
.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::*;
|
||||
@@ -226,23 +274,72 @@ mod tests {
|
||||
let node_id = 42i64;
|
||||
let chunk_index = 0u32;
|
||||
|
||||
let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext).unwrap();
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
||||
|
||||
// Reguläre Entschlüsselung
|
||||
let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag).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);
|
||||
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);
|
||||
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).is_err());
|
||||
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_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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user