feat(compression): implement transparent LZ4 chunk compression with V1 backwards compatibility

This commit is contained in:
2026-09-07 21:59:42 +02:00
parent a9c3dd25a3
commit 2d14c64c3e
7 changed files with 279 additions and 22 deletions
+106 -9
View File
@@ -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);
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ pub async fn mount_container(
.context("Ungültiges Master-Passwort oder Container beschädigt")?;
// WebDAV Filesystem und Handler konfigurieren
let fs = SanctumFs::new(db.clone(), dek);
let fs = SanctumFs::new(db.clone(), dek, meta.version);
let dav_server = DavHandler::builder()
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
+13 -2
View File
@@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{KdfParams, FORMAT_VERSION, MAGIC_BYTES};
use crate::crypto::{KdfParams, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES};
#[allow(dead_code)]
#[derive(Debug, Clone)]
@@ -190,10 +190,11 @@ impl Database {
bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein");
}
if meta.1 != FORMAT_VERSION {
if meta.1 != FORMAT_VERSION_V1 && meta.1 != FORMAT_VERSION_V2 {
bail!("Nicht unterstützte Sanctum-Formatversion: {}", meta.1);
}
if meta.2.len() != 16 {
bail!("Ungültige Salt-Länge im Header");
}
@@ -254,6 +255,16 @@ impl Database {
Ok(())
}
/// Aktualisiert die Version in der meta-Tabelle (z. B. für Migrationen oder Tests).
pub fn set_meta_version(&self, version: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
let rows_affected = conn.execute("UPDATE meta SET version = ?1", params![version])?;
if rows_affected == 0 {
bail!("Konnte Container-Version nicht aktualisieren: meta-Tabelle ist leer");
}
Ok(())
}
/// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf.
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
let normalized = raw_path.trim_matches('/');
+13 -4
View File
@@ -86,6 +86,7 @@ pub struct SanctumFile {
meta: SanctumMetaData,
// (chunk_index, decrypted_payload, is_dirty)
cached_chunk: Option<(u32, Vec<u8>, bool)>,
format_version: u32,
}
impl Debug for SanctumFile {
@@ -94,6 +95,7 @@ impl Debug for SanctumFile {
.field("node_id", &self.node_id)
.field("file_size", &self.file_size)
.field("cursor", &self.cursor)
.field("format_version", &self.format_version)
.finish()
}
}
@@ -103,6 +105,7 @@ impl SanctumFile {
node: NodeRecord,
db: Database,
dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
) -> Self {
let meta = SanctumMetaData {
is_dir: node.is_dir,
@@ -119,14 +122,16 @@ impl SanctumFile {
dek,
meta,
cached_chunk: None,
format_version,
}
}
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück.
fn flush_cached_chunk(&mut self) -> Result<(), FsError> {
if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data).map_err(|e| {
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
})?;
@@ -166,6 +171,7 @@ impl SanctumFile {
&record.ciphertext,
&record.nonce,
&record.tag,
self.format_version,
)
.map_err(|e| {
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
@@ -339,13 +345,15 @@ impl DavFile for SanctumFile {
pub struct SanctumFs {
db: Database,
dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
}
impl SanctumFs {
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>) -> Self {
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self {
Self {
db,
dek: Arc::new(dek),
format_version,
}
}
@@ -432,7 +440,7 @@ impl DavFileSystem for SanctumFs {
}
};
let file = SanctumFile::new(node, self.db.clone(), self.dek.clone());
let file = SanctumFile::new(node, self.db.clone(), self.dek.clone(), self.format_version);
Ok(Box::new(file) as Box<dyn DavFile>)
})
}
@@ -678,11 +686,12 @@ impl DavFileSystem for SanctumFs {
&record.ciphertext,
&record.nonce,
&record.tag,
self.format_version,
)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext)
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
.map_err(|_| FsError::GeneralFailure)?;
self.db