980 lines
41 KiB
Rust
980 lines
41 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, ©_path).await.expect("Copy file");
|
|
let copy_meta = fs.metadata(©_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(©_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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_anti_leak_and_inactivity_shield() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let container_path: PathBuf =
|
|
temp_dir.join(format!("test_leak_shield_{}.sanctum", std::process::id()));
|
|
|
|
if container_path.exists() {
|
|
let _ = std::fs::remove_file(&container_path);
|
|
}
|
|
|
|
let password = "ShieldedContainerPass2026!";
|
|
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("derive kek");
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap dek");
|
|
|
|
let db = Database::open(&container_path).expect("open db");
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
|
.expect("init schema");
|
|
db.checkpoint().expect("checkpoint");
|
|
|
|
// 1. Erstelle Filesystem mit aktivem Anti-Leak Shield (Standard)
|
|
let fs = SanctumFs::with_options(db.clone(), dek.clone(), FORMAT_VERSION, true);
|
|
|
|
// 2. Windows Explorer Leck-Dateien müssen abgewehrt werden
|
|
let leak_files = [
|
|
"/Thumbs.db",
|
|
"/thumbs.db",
|
|
"/desktop.ini",
|
|
"/Desktop.ini",
|
|
"/Folder.jpg",
|
|
"/AlbumArt_{A1B2C3D4-E5F6}_Large.jpg",
|
|
"/albumartsmall.jpg",
|
|
"/ehthumbs.db",
|
|
"/autorun.inf",
|
|
];
|
|
|
|
for leak in leak_files {
|
|
let path = DavPath::new(leak).unwrap();
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let res = fs.open(&path, opts).await;
|
|
assert!(
|
|
matches!(res, Err(dav_server::fs::FsError::Forbidden)),
|
|
"Leak-Datei '{}' muss mit FsError::Forbidden blockiert werden",
|
|
leak
|
|
);
|
|
}
|
|
|
|
// Ordnererstellung mit Leck-Namen muss ebenfalls blockiert werden
|
|
let leak_dir = DavPath::new("/Thumbs.db").unwrap();
|
|
let dir_res = fs.create_dir(&leak_dir).await;
|
|
assert!(matches!(dir_res, Err(dav_server::fs::FsError::Forbidden)));
|
|
|
|
// 3. Legitime Dateien müssen reibungslos funktionieren
|
|
let legit_path = DavPath::new("/top_secret.docx").unwrap();
|
|
let mut opts_legit = OpenOptions::default();
|
|
opts_legit.write = true;
|
|
opts_legit.create_new = true;
|
|
let mut file = fs.open(&legit_path, opts_legit).await.expect("create legit file");
|
|
file.write_bytes(Bytes::from_static(b"Sanctum OpSec Shield Test"))
|
|
.await
|
|
.expect("write data");
|
|
file.flush().await.expect("flush data");
|
|
drop(file);
|
|
|
|
// 4. Inaktivitäts-Tracking überprüfen
|
|
let activity_tracker = fs.last_activity();
|
|
let recorded_now = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
|
|
assert!(recorded_now > 0);
|
|
|
|
// Zurückdatieren um 300 Sekunden
|
|
activity_tracker.store(recorded_now - 300, std::sync::atomic::Ordering::Relaxed);
|
|
let past = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
|
|
|
|
// Datei lesen
|
|
let mut opts_read = OpenOptions::default();
|
|
opts_read.read = true;
|
|
let mut rf = fs.open(&legit_path, opts_read).await.expect("open legit");
|
|
let content = rf.read_bytes(25).await.expect("read bytes");
|
|
assert_eq!(&content[..], b"Sanctum OpSec Shield Test");
|
|
drop(rf);
|
|
|
|
let updated_time = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
|
|
assert!(
|
|
updated_time > past,
|
|
"VFS-Aktivität muss den Inaktivitäts-Timer aktualisieren"
|
|
);
|
|
|
|
// 5. Deaktivierter Shield (--no-anti-leak) erlaubt Explorer-Metadaten
|
|
let unshielded_fs = SanctumFs::with_options(db.clone(), dek.clone(), FORMAT_VERSION, false);
|
|
let mut unshielded_opts = OpenOptions::default();
|
|
unshielded_opts.write = true;
|
|
unshielded_opts.create_new = true;
|
|
let desktop_ini_path = DavPath::new("/desktop.ini").unwrap();
|
|
let desktop_res = unshielded_fs.open(&desktop_ini_path, unshielded_opts).await;
|
|
assert!(
|
|
desktop_res.is_ok(),
|
|
"Ohne Shield muss Erstellung von desktop.ini gestattet sein"
|
|
);
|
|
|
|
// Aufräumen
|
|
let _ = std::fs::remove_file(&container_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_hidden_vault_and_storage_compaction_integration() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let container_path: PathBuf = temp_dir.join(format!("test_hidden_{}.sanctum", std::process::id()));
|
|
|
|
if container_path.exists() {
|
|
let _ = std::fs::remove_file(&container_path);
|
|
}
|
|
|
|
let password_decoy = "DecoyOuterPassword2026!";
|
|
let password_hidden = "TopSecretHiddenPassword2026!";
|
|
|
|
// 1. Dual-Vault Initialisierung (Slot 0 = Decoy, Slot 1 = Hidden Vault)
|
|
let salt0 = generate_salt();
|
|
let salt1 = generate_salt();
|
|
let kdf_params = KdfParams {
|
|
memory_cost: 1024,
|
|
time_cost: 1,
|
|
parallelism: 1,
|
|
};
|
|
|
|
let kek0 = derive_kek(password_decoy, &salt0, &kdf_params).expect("KEK 0");
|
|
let kek1 = derive_kek(password_hidden, &salt1, &kdf_params).expect("KEK 1");
|
|
|
|
let dek0 = generate_dek();
|
|
let dek1 = generate_dek();
|
|
|
|
let (wrapped_dek0, nonce0, tag0) = wrap_dek(&kek0, &dek0).expect("Wrap DEK 0");
|
|
let (wrapped_dek1, nonce1, tag1) = wrap_dek(&kek1, &dek1).expect("Wrap DEK 1");
|
|
|
|
let db = Database::open(&container_path).expect("Open container");
|
|
db.init_schema_with_hidden(
|
|
&salt0,
|
|
&kdf_params,
|
|
&wrapped_dek0,
|
|
&nonce0,
|
|
&tag0,
|
|
Some((&salt1, &kdf_params, &wrapped_dek1, &nonce1, &tag1)),
|
|
)
|
|
.expect("Init schema with hidden");
|
|
db.checkpoint().expect("Checkpoint init");
|
|
|
|
// 2. Multi-Slot Authentifizierungsprüfung
|
|
let meta = db.read_meta().expect("Read meta");
|
|
assert_eq!(meta.slots.len(), 2, "Es müssen 2 Slots initialisiert sein");
|
|
|
|
// Decoy Passwort entsperrt Slot 0
|
|
let mut auth_decoy = None;
|
|
for slot in &meta.slots {
|
|
if let Ok(kek) = derive_kek(password_decoy, &slot.kdf_salt, &slot.kdf_params) {
|
|
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
|
|
auth_decoy = Some((dek, slot.slot_id));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let (unwrapped_dek0, slot_id0) = auth_decoy.expect("Decoy password must unwrap");
|
|
assert_eq!(slot_id0, 0);
|
|
assert_eq!(*unwrapped_dek0, *dek0);
|
|
|
|
// Hidden Passwort entsperrt Slot 1
|
|
let mut auth_hidden = None;
|
|
for slot in &meta.slots {
|
|
if let Ok(kek) = derive_kek(password_hidden, &slot.kdf_salt, &slot.kdf_params) {
|
|
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
|
|
auth_hidden = Some((dek, slot.slot_id));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let (unwrapped_dek1, slot_id1) = auth_hidden.expect("Hidden password must unwrap");
|
|
assert_eq!(slot_id1, 1);
|
|
assert_eq!(*unwrapped_dek1, *dek1);
|
|
|
|
// 3. VFS Operationen im Decoy-Vault (Slot 0)
|
|
let fs_decoy = SanctumFs::with_vault(db.clone(), unwrapped_dek0, meta.version, true, 0);
|
|
let decoy_file_path = DavPath::new("/harmless_recipe.txt").unwrap();
|
|
|
|
let mut opts_write = OpenOptions::default();
|
|
opts_write.write = true;
|
|
opts_write.create_new = true;
|
|
let mut df = fs_decoy.open(&decoy_file_path, opts_write).await.expect("Open decoy file");
|
|
df.write_bytes(Bytes::from_static(b"Apples, Flour, Sugar, Butter"))
|
|
.await
|
|
.expect("Write decoy");
|
|
df.flush().await.expect("Flush decoy");
|
|
drop(df);
|
|
|
|
// 4. VFS Operationen im Hidden-Vault (Slot 1)
|
|
let fs_hidden = SanctumFs::with_vault(db.clone(), unwrapped_dek1, meta.version, true, 1);
|
|
let hidden_file_path = DavPath::new("/classified_report.pdf").unwrap();
|
|
|
|
let mut opts_write2 = OpenOptions::default();
|
|
opts_write2.write = true;
|
|
opts_write2.create_new = true;
|
|
let mut hf = fs_hidden.open(&hidden_file_path, opts_write2).await.expect("Open hidden file");
|
|
hf.write_bytes(Bytes::from_static(b"TOP SECRET INTELLIGENCE DATA"))
|
|
.await
|
|
.expect("Write hidden");
|
|
hf.flush().await.expect("Flush hidden");
|
|
drop(hf);
|
|
|
|
// 5. Strikte Isolierung verifizieren: Decoy-Vault sieht NICHTS vom Hidden-Vault
|
|
let mut stream_decoy = fs_decoy
|
|
.read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None)
|
|
.await
|
|
.expect("read_dir decoy");
|
|
let mut decoy_entries = Vec::new();
|
|
while let Some(Ok(entry)) = stream_decoy.next().await {
|
|
decoy_entries.push(String::from_utf8_lossy(&entry.name()).to_string());
|
|
}
|
|
assert_eq!(decoy_entries, vec!["harmless_recipe.txt"]);
|
|
assert!(fs_decoy.metadata(&hidden_file_path).await.is_err(), "Decoy darf classified_report nicht sehen");
|
|
|
|
// Hidden-Vault sieht ebenfalls nur seine eigenen Dateien
|
|
let mut stream_hidden = fs_hidden
|
|
.read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None)
|
|
.await
|
|
.expect("read_dir hidden");
|
|
let mut hidden_entries = Vec::new();
|
|
while let Some(Ok(entry)) = stream_hidden.next().await {
|
|
hidden_entries.push(String::from_utf8_lossy(&entry.name()).to_string());
|
|
}
|
|
assert_eq!(hidden_entries, vec!["classified_report.pdf"]);
|
|
assert!(fs_hidden.metadata(&decoy_file_path).await.is_err(), "Hidden darf harmless_recipe nicht auflösen");
|
|
|
|
// 6. Forensische Dateiprüfung (Anti-Forensics / Plausible Deniability)
|
|
db.checkpoint().expect("Checkpoint before raw inspection");
|
|
let raw_bytes = std::fs::read(&container_path).expect("Read container raw bytes");
|
|
|
|
// Der Dateiname "classified_report.pdf" darf NIRGENDS im Rohformat im Container stehen
|
|
let needle_filename = b"classified_report";
|
|
assert!(
|
|
!raw_bytes.windows(needle_filename.len()).any(|w| w == needle_filename),
|
|
"Forensischer Leak: Dateiname des Hidden Vaults taucht als Klartext in der Datei auf!"
|
|
);
|
|
|
|
let needle_payload = b"TOP SECRET INTELLIGENCE DATA";
|
|
assert!(
|
|
!raw_bytes.windows(needle_payload.len()).any(|w| w == needle_payload),
|
|
"Forensischer Leak: Nutzlast des Hidden Vaults taucht als Klartext auf!"
|
|
);
|
|
|
|
// 7. Storage Compaction (Incremental Vacuum & Chunk Shredding)
|
|
// Große temporäre Datei im Hidden Vault anlegen
|
|
let temp_large_path = DavPath::new("/large_dump.dat").unwrap();
|
|
let mut opts_dump = OpenOptions::default();
|
|
opts_dump.write = true;
|
|
opts_dump.create_new = true;
|
|
let mut dump_file = fs_hidden.open(&temp_large_path, opts_dump).await.expect("Open dump");
|
|
let mut large_buffer = vec![0u8; 512 * 1024]; // 512 KB echte Zufallsdaten (Shannon-Entropie 8.0)
|
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut large_buffer);
|
|
dump_file.write_bytes(Bytes::copy_from_slice(&large_buffer)).await.expect("Write dump");
|
|
dump_file.flush().await.expect("Flush dump");
|
|
drop(dump_file);
|
|
|
|
db.checkpoint().expect("Checkpoint after write");
|
|
|
|
// Datei löschen (löst automatisches Chunk-Shredding aus)
|
|
fs_hidden.remove_file(&temp_large_path).await.expect("Remove dump");
|
|
db.checkpoint().expect("Checkpoint after remove");
|
|
|
|
let free_pages = db.freelist_count().expect("freelist count");
|
|
assert!(free_pages > 0, "Nach dem Löschen müssen freie Seiten in der Freelist existieren");
|
|
|
|
// Incremental Vacuum ausführen
|
|
let reclaimed = db.incremental_vacuum(None).expect("incremental vacuum");
|
|
assert!(reclaimed > 0, "Seiten müssen an das Dateisystem zurückgegeben werden");
|
|
assert_eq!(db.freelist_count().unwrap(), 0, "Freelist muss jetzt 0 sein");
|
|
|
|
// Verifiziere, dass verbleibende Dateien in beiden Vaults intakt lesbar sind
|
|
let mut r_decoy = fs_decoy.open(&decoy_file_path, OpenOptions::default()).await.expect("open decoy");
|
|
let d_data = r_decoy.read_bytes(30).await.expect("read decoy");
|
|
assert_eq!(&d_data[..], b"Apples, Flour, Sugar, Butter");
|
|
|
|
let mut r_hidden = fs_hidden.open(&hidden_file_path, OpenOptions::default()).await.expect("open hidden");
|
|
let h_data = r_hidden.read_bytes(30).await.expect("read hidden");
|
|
assert_eq!(&h_data[..], b"TOP SECRET INTELLIGENCE DATA");
|
|
|
|
// Aufräumen
|
|
let _ = std::fs::remove_file(&container_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id()));
|
|
let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id()));
|
|
let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id()));
|
|
|
|
if path_standard.exists() {
|
|
let _ = std::fs::remove_file(&path_standard);
|
|
}
|
|
if path_dual.exists() {
|
|
let _ = std::fs::remove_file(&path_dual);
|
|
}
|
|
if backup_path.exists() {
|
|
let _ = std::fs::remove_file(&backup_path);
|
|
}
|
|
|
|
let pass_decoy = "OuterDecoyPassphrase2026!";
|
|
let pass_hidden = "InnerHiddenVaultPass2026!";
|
|
let kdf_params = KdfParams {
|
|
memory_cost: 1024,
|
|
time_cost: 1,
|
|
parallelism: 1,
|
|
};
|
|
|
|
// 1. Erstelle Standard-Container (Container A)
|
|
let salt_std = generate_salt();
|
|
let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap();
|
|
let dek_std = generate_dek();
|
|
let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap();
|
|
|
|
let db_std = Database::open(&path_standard).unwrap();
|
|
db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap();
|
|
db_std.checkpoint().unwrap();
|
|
|
|
// 2. Erstelle Dual-Vault Container (Container B)
|
|
let salt_b0 = generate_salt();
|
|
let salt_b1 = generate_salt();
|
|
let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap();
|
|
let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap();
|
|
let dek_b0 = generate_dek();
|
|
let dek_b1 = generate_dek();
|
|
let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap();
|
|
let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap();
|
|
|
|
let db_dual = Database::open(&path_dual).unwrap();
|
|
db_dual.init_schema_with_hidden(
|
|
&salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0,
|
|
Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)),
|
|
).unwrap();
|
|
db_dual.checkpoint().unwrap();
|
|
|
|
// 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit
|
|
let conn_std = rusqlite::Connection::open(&path_standard).unwrap();
|
|
let conn_dual = rusqlite::Connection::open(&path_dual).unwrap();
|
|
|
|
// A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern
|
|
for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] {
|
|
let v_nodes: i64 = conn.query_row(
|
|
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0)
|
|
).unwrap();
|
|
assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name);
|
|
|
|
let v_chunks: i64 = conn.query_row(
|
|
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0)
|
|
).unwrap();
|
|
assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name);
|
|
|
|
// Genau 2 Slots in meta
|
|
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap();
|
|
assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name);
|
|
|
|
// Genau 2 Root-Nodes in nodes (id=1 und id=2)
|
|
let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap();
|
|
assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name);
|
|
}
|
|
drop(conn_std);
|
|
drop(conn_dual);
|
|
|
|
// 4. Dateien in beiden Vaults von Container B anlegen
|
|
let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0);
|
|
let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1);
|
|
|
|
let decoy_path = DavPath::new("/family_recipe.txt").unwrap();
|
|
let hidden_path = DavPath::new("/private_journal.docx").unwrap();
|
|
|
|
let mut opts_w = OpenOptions::default();
|
|
opts_w.write = true;
|
|
opts_w.create_new = true;
|
|
|
|
let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap();
|
|
f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap();
|
|
f_d.flush().await.unwrap();
|
|
drop(f_d);
|
|
|
|
let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap();
|
|
f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap();
|
|
f_h.flush().await.unwrap();
|
|
drop(f_h);
|
|
|
|
db_dual.checkpoint().unwrap();
|
|
|
|
// 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite
|
|
let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap();
|
|
let hidden_node_name: String = conn_dual2.query_row(
|
|
"SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0)
|
|
).unwrap();
|
|
assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!");
|
|
assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!");
|
|
assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein");
|
|
|
|
let count_dollar_h: i64 = conn_dual2.query_row(
|
|
"SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0)
|
|
).unwrap();
|
|
assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren");
|
|
drop(conn_dual2);
|
|
|
|
// 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG
|
|
let meta_dual = db_dual.read_meta().unwrap();
|
|
let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy");
|
|
assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren");
|
|
assert_eq!(*auth_decoy.0, *dek_b0);
|
|
|
|
let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden");
|
|
assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren");
|
|
assert_eq!(*auth_hidden.0, *dek_b1);
|
|
|
|
assert!(meta_dual.authenticate("WrongPassword123!").is_none());
|
|
|
|
// 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT
|
|
// Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden!
|
|
let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap();
|
|
assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors);
|
|
assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden");
|
|
assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden");
|
|
|
|
// Hidden-Prüfung: Ebenfalls gesund!
|
|
let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap();
|
|
assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors);
|
|
assert_eq!(report_hidden.corrupted_chunks, 0);
|
|
assert_eq!(report_hidden.orphan_nodes, 0);
|
|
|
|
// 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten!
|
|
export_header_backup(&path_dual, &backup_path).unwrap();
|
|
|
|
// Zerstöre Header
|
|
let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap();
|
|
conn_wipe.execute("DELETE FROM meta", []).unwrap();
|
|
drop(conn_wipe);
|
|
|
|
// Stelle wieder her
|
|
restore_header_backup(&path_dual, &backup_path).unwrap();
|
|
|
|
// Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos!
|
|
let restored_meta = db_dual.read_meta().unwrap();
|
|
let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore");
|
|
assert_eq!(res_decoy.2, 0);
|
|
let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore");
|
|
assert_eq!(res_hidden.2, 1);
|
|
|
|
// 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört!
|
|
let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap();
|
|
let brand_new_decoy_pass = "BrandNewDecoyPassword2026!";
|
|
restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap();
|
|
|
|
let rescued_meta = db_dual.read_meta().unwrap();
|
|
// Neues Decoy Passwort entsperrt Slot 0
|
|
let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass");
|
|
assert_eq!(rescued_decoy.2, 0);
|
|
assert_eq!(*rescued_decoy.0, *dek_b0);
|
|
|
|
// Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!)
|
|
let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved");
|
|
assert_eq!(rescued_hidden.2, 1);
|
|
assert_eq!(*rescued_hidden.0, *dek_b1);
|
|
|
|
// Aufräumen
|
|
let _ = std::fs::remove_file(&path_standard);
|
|
let _ = std::fs::remove_file(&path_dual);
|
|
let _ = std::fs::remove_file(&backup_path);
|
|
}
|
|
|
|
|
|
|
|
|