Files
sanctum/tests/integration_test.rs
T

502 lines
21 KiB
Rust

use std::io::SeekFrom;
use std::path::PathBuf;
use bytes::Bytes;
use dav_server::{
davpath::DavPath,
fs::{DavFileSystem, OpenOptions, ReadDirMeta},
};
use futures_util::StreamExt;
use sanctum::{
crypto::{
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
},
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
};
#[tokio::test]
async fn test_sanctum_full_container_lifecycle() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_{}.sanctum", std::process::id()));
// Aufräumen, falls alte Testdatei existiert
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "CorrectMasterPassword2026!";
let wrong_password = "WrongMasterPassword!";
// 1. Initialisierung
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024, // Schnell für Tests
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).expect("DEK wrapping");
let db = Database::open(&container_path).expect("Open database");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.expect("Init schema");
db.checkpoint().expect("Checkpoint");
// 2. Header & Magic Bytes Prüfung
let meta = db.read_meta().expect("Read meta");
assert_eq!(meta.version, FORMAT_VERSION);
assert_eq!(meta.kdf_salt, salt);
assert_eq!(meta.wrapped_dek, wrapped_dek);
// Falsches Passwort schlägt fehl
let wrong_kek = derive_kek(wrong_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
assert!(unwrap_dek(&wrong_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).is_err());
// Richtiges Passwort entschlüsselt DEK
let unwrapped_dek =
unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).unwrap();
assert_eq!(*dek, *unwrapped_dek);
// 3. VFS Filesystem-Operationen (WebDAV Trait)
let fs = SanctumFs::new(db.clone(), unwrapped_dek, meta.version);
// Ordner erstellen
let docs_path = DavPath::new("/documents").unwrap();
fs.create_dir(&docs_path).await.expect("Create /documents");
let docs_meta = fs.metadata(&docs_path).await.expect("Metadata /documents");
assert!(docs_meta.is_dir());
// Multi-MB Datei über mehrere Chunks hinweg schreiben (2.5 MB = 3 Chunks à 1 MB)
let file_path = DavPath::new("/documents/large_payload.bin").unwrap();
let mut opts_write = OpenOptions::default();
opts_write.write = true;
opts_write.create_new = true;
let mut file = fs.open(&file_path, opts_write).await.expect("Open for write");
let payload_size = (2.5 * CHUNK_SIZE as f64) as usize;
let mut sample_data = Vec::with_capacity(payload_size);
for i in 0..payload_size {
sample_data.push((i % 251) as u8);
}
file.write_bytes(Bytes::copy_from_slice(&sample_data))
.await
.expect("Write 2.5 MB");
file.flush().await.expect("Flush file");
drop(file);
// Metadaten verifizieren
let file_meta = fs.metadata(&file_path).await.expect("Metadata file");
assert_eq!(file_meta.len(), payload_size as u64);
assert!(!file_meta.is_dir());
// Datei lesen & Seek über Chunk-Grenzen testen
let mut opts_read = OpenOptions::default();
opts_read.read = true;
let mut read_file = fs.open(&file_path, opts_read).await.expect("Open for read");
// Seek mitten in den 2. Chunk (1 MB + 500 Bytes)
let seek_offset = (CHUNK_SIZE + 500) as u64;
let new_pos = read_file
.seek(SeekFrom::Start(seek_offset))
.await
.expect("Seek");
assert_eq!(new_pos, seek_offset);
// 2000 Bytes lesen (überschreitet evtl. Chunk-Grenze oder bleibt im Chunk)
let read_len = 2000;
let read_chunk_bytes = read_file.read_bytes(read_len).await.expect("Read bytes");
assert_eq!(read_chunk_bytes.len(), read_len);
assert_eq!(
&read_chunk_bytes[..],
&sample_data[seek_offset as usize..seek_offset as usize + read_len]
);
drop(read_file);
// Datei umbenennen
let renamed_path = DavPath::new("/documents/renamed_payload.bin").unwrap();
fs.rename(&file_path, &renamed_path).await.expect("Rename file");
assert!(fs.metadata(&file_path).await.is_err());
let renamed_meta = fs.metadata(&renamed_path).await.expect("Metadata renamed");
assert_eq!(renamed_meta.len(), payload_size as u64);
// Datei kopieren
let copy_path = DavPath::new("/documents/copy_payload.bin").unwrap();
fs.copy(&renamed_path, &copy_path).await.expect("Copy file");
let copy_meta = fs.metadata(&copy_path).await.expect("Metadata copy");
assert_eq!(copy_meta.len(), payload_size as u64);
// Verzeichnis auflisten
let stream = fs.read_dir(&docs_path, ReadDirMeta::None).await.expect("Read dir");
let entries: Vec<_> = stream.collect().await;
assert_eq!(entries.len(), 2); // renamed_payload.bin & copy_payload.bin
// Dateien und Verzeichnis löschen
fs.remove_file(&renamed_path).await.expect("Remove renamed");
fs.remove_file(&copy_path).await.expect("Remove copy");
fs.remove_dir(&docs_path).await.expect("Remove dir");
// Prüfen, dass Root leer ist
let root_path = DavPath::new("/").unwrap();
let root_stream = fs.read_dir(&root_path, ReadDirMeta::None).await.expect("Read root");
let root_entries: Vec<_> = root_stream.collect().await;
assert_eq!(root_entries.len(), 0);
// Finaler DB Checkpoint
db.checkpoint().expect("Final checkpoint");
drop(fs);
drop(db);
// Verifizieren, dass die Containerdatei existiert und aufgeräumt werden kann
assert!(container_path.exists());
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_password_change() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_passwd_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password_v1 = "InitialSecret123!";
let password_v2 = "NewSecret456!";
// 1. Initialisierung mit Passwort v1
let salt_v1 = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
let original_dek = generate_dek();
let (wrapped_dek_v1, nonce_v1, tag_v1) =
wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1");
let db = Database::open(&container_path).expect("Open database");
db.init_schema(&salt_v1, &kdf_params, &wrapped_dek_v1, &nonce_v1, &tag_v1)
.expect("Init schema");
db.checkpoint().expect("Checkpoint");
// Datei im Container mit DEK anlegen
let fs_v1 = SanctumFs::new(db.clone(), original_dek.clone(), FORMAT_VERSION);
let file_path = DavPath::new("/secret.txt").unwrap();
let file_data = b"Verschluesselte Geheimdaten vor der Passwortaenderung!";
let mut opts_write = OpenOptions::default();
opts_write.write = true;
opts_write.create_new = true;
let mut write_file = fs_v1
.open(&file_path, opts_write)
.await
.expect("Open write");
write_file
.write_bytes(Bytes::from_static(file_data))
.await
.expect("Write bytes");
write_file.flush().await.expect("Flush");
drop(write_file);
drop(fs_v1);
db.checkpoint().expect("Checkpoint");
// 2. Passwortwechsel durchführen
let meta_before = db.read_meta().expect("Read meta");
let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params).expect("Derive old KEK");
let recovered_dek = unwrap_dek(&old_kek, &meta_before.wrapped_dek, &meta_before.header_nonce, &meta_before.header_tag)
.expect("Unwrap with old password");
let salt_v2 = generate_salt();
let kek_v2 = derive_kek(password_v2, &salt_v2, &kdf_params).expect("Derive new KEK");
let (wrapped_dek_v2, nonce_v2, tag_v2) = wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK");
db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2).expect("Update meta keys");
db.checkpoint().expect("Checkpoint");
// 3. Verifikation: Altes Passwort darf NICHT mehr funktionieren
let meta_after = db.read_meta().expect("Read meta after");
let old_kek_again = derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap();
assert!(
unwrap_dek(&old_kek_again, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag).is_err(),
"Altes Passwort darf nach Passwortaenderung nicht mehr funktionieren!"
);
// 4. Verifikation: Neues Passwort funktioniert und entschlüsselt alte Daten intakt
let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params).expect("Derive new KEK");
let active_dek = unwrap_dek(&new_kek, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag)
.expect("Unwrap with new password");
let fs_v2 = SanctumFs::new(db.clone(), active_dek, meta_after.version);
let mut opts_read = OpenOptions::default();
opts_read.read = true;
let mut read_file = fs_v2.open(&file_path, opts_read).await.expect("Open read with new password");
let read_bytes = read_file.read_bytes(file_data.len()).await.expect("Read bytes");
assert_eq!(&read_bytes[..], file_data, "Daten muessen nach Passwortaenderung unveraendert lesbar sein!");
drop(read_file);
drop(fs_v2);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_lz4_compression_efficiency() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_compress_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "CompressTestPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
// 2 MB hochkompressible Textdaten erzeugen (z. B. wiederholende Zeilen)
let pattern = b"Sanctum high performance encrypted container storage with transparent LZ4 compression.\n";
let target_size = 2 * 1024 * 1024;
let mut repetitive_data = Vec::with_capacity(target_size);
while repetitive_data.len() < target_size {
repetitive_data.extend_from_slice(pattern);
}
repetitive_data.truncate(target_size);
// Datei über VFS schreiben
let test_file_path = DavPath::new("/compressed_test.log").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut file = fs.open(&test_file_path, opts).await.expect("open write");
file.write_bytes(Bytes::copy_from_slice(&repetitive_data)).await.expect("write");
file.flush().await.expect("flush");
drop(file);
db.checkpoint().expect("checkpoint");
// Chunk-Knoten ermitteln und Ciphertext-Größe in der Datenbank prüfen
let node = db.resolve_path("/compressed_test.log").expect("resolve").expect("found");
assert_eq!(node.size, target_size as u64);
let chunk0 = db.read_chunk(node.id, 0).expect("read chunk").expect("chunk 0 exists");
// 1 MB Rohdaten komprimiert mit LZ4 sollte typischerweise < 100 KB sein
assert!(
chunk0.ciphertext.len() < 100_000,
"Ciphertext should be compressed from 1MB to < 100KB, was {}",
chunk0.ciphertext.len()
);
// Datei zurücklesen und mit Original vergleichen
let mut read_opts = OpenOptions::default();
read_opts.read = true;
let mut read_file = fs.open(&test_file_path, read_opts).await.expect("open read");
let read_back = read_file.read_bytes(target_size).await.expect("read back");
assert_eq!(read_back.len(), target_size);
assert_eq!(&read_back[..], &repetitive_data[..]);
drop(read_file);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_v1_backward_compatibility() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_v1_compat_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "V1LegacyPassword!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
// Formatversion auf V1 setzen
db.set_meta_version(FORMAT_VERSION_V1).expect("set v1");
let meta = db.read_meta().expect("read meta");
assert_eq!(meta.version, FORMAT_VERSION_V1);
// Datei-Knoten direkt in DB erstellen
let node = db.create_node(1, "legacy_v1.txt", false).expect("create node");
// V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben
let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload.";
let (ct, nonce, tag) = encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1");
db.write_chunk(node.id, 0, &nonce, &tag, &ct).expect("write chunk");
db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678).expect("update size");
db.checkpoint().expect("checkpoint");
// Öffnen über SanctumFs konfiguriert für V1
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION_V1);
let path = DavPath::new("/legacy_v1.txt").unwrap();
let mut opts = OpenOptions::default();
opts.read = true;
let mut file = fs.open(&path, opts).await.expect("open v1 file");
let read_data = file.read_bytes(v1_plaintext.len()).await.expect("read v1");
assert_eq!(&read_data[..], v1_plaintext);
drop(file);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_disaster_recovery_workflow() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let initial_password = "PrimaryPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
// 1. Container erstellen und Datei schreiben
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/confidential.doc").unwrap();
let secret_payload = b"Top secret corporate documents protected by Sanctum.";
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f = fs.open(&test_file, opts_w).await.expect("create file");
f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write");
f.flush().await.expect("flush");
drop(f);
drop(fs);
db.checkpoint().expect("checkpoint");
// 2. Header-Backup exportieren & 24-Wort Notfallschlüssel sichern
export_header_backup(&container_path, &backup_path).expect("Export header backup");
assert!(backup_path.exists());
let recovery_phrase = dek_to_mnemonic(&dek).expect("Generate 24-word recovery phrase");
assert_eq!(recovery_phrase.split_whitespace().count(), 24);
// 3. Integritätsprüfung vor Katastrophe
let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial");
assert!(initial_report.is_healthy());
assert_eq!(initial_report.total_files, 1);
assert_eq!(initial_report.corrupted_chunks, 0);
// 4. KATASTROPHE 1: Header in SQLite löschen
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Container darf sich ohne Header nicht mehr öffnen lassen
let broken_db = Database::open(&container_path).unwrap();
assert!(broken_db.read_meta().is_err());
drop(broken_db);
// 5. WIEDERHERSTELLUNG 1: Aus externem .sanctum.hdr Backup restoren
restore_header_backup(&container_path, &backup_path).expect("Restore from backup file");
let restored_db = Database::open(&container_path).unwrap();
let restored_meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap();
let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap();
let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version);
let mut opts_r = OpenOptions::default();
opts_r.read = true;
let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored");
let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore");
drop(rf1);
drop(fs1);
drop(restored_db);
// 6. KATASTROPHE 2: Header erneut zerstört UND ursprüngliches Passwort vergessen!
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// 7. WIEDERHERSTELLUNG 2: Via 24-Wort Notfallschlüssel mit BRANDNEUEM Passwort
let brand_new_password = "BrandNewRescuedVaultPassphrase2026!";
restore_header_from_recovery_key(&container_path, &recovery_phrase, brand_new_password)
.expect("Restore from 24-word recovery key");
let rescued_db = Database::open(&container_path).unwrap();
let rescued_meta = rescued_db.read_meta().expect("Read rescued meta");
let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap();
let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap();
let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version);
let mut opts_r2 = OpenOptions::default();
opts_r2.read = true;
let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password");
let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue");
drop(rf2);
drop(fs2);
drop(rescued_db);
// 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert
let conn = rusqlite::Connection::open(&container_path).unwrap();
let mut current_ct: Vec<u8> = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap();
current_ct[0] ^= 0x01; // Bit-Flip
conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap();
drop(conn);
let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot");
assert!(!bitrot_report.is_healthy(), "Container must flag bitrot");
assert_eq!(bitrot_report.corrupted_chunks, 1);
// Aufräumen
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}