feat(anti-forensics): implement incremental auto-vacuum, chunk shredding, and plausible deniability

This commit is contained in:
2026-09-08 10:46:24 +02:00
parent 2cb065c8c0
commit 1e6854e9f5
8 changed files with 1386 additions and 270 deletions
+87
View File
@@ -129,6 +129,70 @@ pub fn unwrap_dek(
Ok(dek) Ok(dek)
} }
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter KDF/DEK-Slot. 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; 32];
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)
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind.
pub fn encrypt_node_name(dek: &[u8; 32], 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 tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer)
.expect("Name encryption");
format!(
"$h${}${}${}",
hex::encode(nonce_bytes),
hex::encode(tag.as_slice()),
hex::encode(&buffer)
)
}
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
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()?;
let mut buffer = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
}
}
}
}
None
}
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme. /// 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> { pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
let mnemonic = bip39::Mnemonic::from_entropy(dek) let mnemonic = bip39::Mnemonic::from_entropy(dek)
@@ -412,5 +476,28 @@ mod tests {
let corrupted_phrase = words.join(" "); let corrupted_phrase = words.join(" ");
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); 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 encrypted = encrypt_node_name(&dek, filename);
assert!(encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename));
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename);
// Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
// Dummy-Slot hat korrekte Längen
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
assert_eq!(dummy_dek.len(), 32);
assert_eq!(dummy_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.len(), 16);
}
} }
+250 -60
View File
@@ -33,6 +33,21 @@ enum Commands {
/// Pfad zur zu erstellenden .sanctum Containerdatei /// Pfad zur zu erstellenden .sanctum Containerdatei
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, path: PathBuf,
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)]
with_hidden: bool,
},
/// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
Compact {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Maximale Anzahl an Seiten, die freigegeben werden sollen (Standard: alle freien Seiten)
#[arg(short, long)]
pages: Option<usize>,
}, },
/// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein /// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein
@@ -157,7 +172,7 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase()) Ok(ch.to_ascii_uppercase())
} }
fn handle_init(container_path: &Path) -> Result<()> { fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
if container_path.exists() { if container_path.exists() {
bail!( bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
@@ -169,65 +184,208 @@ fn handle_init(container_path: &Path) -> Result<()> {
println!("│ Sanctum — Neuen verschlüsselten Container erstellen │"); println!("│ Sanctum — Neuen verschlüsselten Container erstellen │");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display()); println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault"));
}
println!(); println!();
let password = rpassword::prompt_password("Master-Passwort eingeben: ") if with_hidden {
.context("Fehler beim Einlesen des Passworts")?; println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if password_0.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
let confirm_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if password_0 != confirm_0 {
bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!");
}
if password.trim().is_empty() { println!();
bail!("Das Master-Passwort darf nicht leer sein."); println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
let password_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if password_1.trim().is_empty() {
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
}
if password_1 == password_0 {
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
}
let confirm_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if password_1 != confirm_1 {
bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!");
}
println!();
ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...");
let salt_0 = generate_salt();
let kdf_params_0 = KdfParams::default();
let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?;
let salt_1 = generate_salt();
let kdf_params_1 = KdfParams::default();
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG...");
let dek_0 = generate_dek();
let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM...");
let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_hidden(
&salt_0,
&kdf_params_0,
&wrapped_dek_0,
&nonce_0,
&tag_0,
Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_1)),
)?;
db.checkpoint()?;
let phrase_0 = dek_to_mnemonic(&dek_0)?;
let phrase_1 = dek_to_mnemonic(&dek_1)?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!(" • Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung");
println!(" • Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)");
println!();
println!(" ┌─────────────────────────────────────────────────────────┐");
println!(" │ NOTFALLSCHLÜSSEL — SLOT 0 (STANDARD / DECOY VAULT) │");
println!(" └─────────────────────────────────────────────────────────┘");
ui::print_recovery_phrase_card(&phrase_0);
println!(" ┌─────────────────────────────────────────────────────────┐");
println!(" │ NOTFALLSCHLÜSSEL — SLOT 1 (HIDDEN VAULT) │");
println!(" └─────────────────────────────────────────────────────────┘");
ui::print_recovery_phrase_card(&phrase_1);
} else {
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if password != confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
println!();
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
let salt = generate_salt();
let kdf_params = KdfParams::default();
let kek = derive_kek(&password, &salt, &kdf_params)
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
let dek = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?;
let recovery_phrase =
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
println!();
ui::print_recovery_phrase_card(&recovery_phrase);
} }
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ") Ok(())
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; }
if password != confirm_password { fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
bail!("Die eingegebenen Passwörter stimmen nicht überein!"); if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
} }
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Storage-Kompaktierung (Anti-Forensics) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!(); println!();
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
let salt = generate_salt();
let kdf_params = KdfParams::default();
let kek = derive_kek(&password, &salt, &kdf_params)
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
let dek = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) let freelist_before = db.freelist_count()
.context("Fehler bei der Schema-Initialisierung")?; .context("Konnte Freelist-Größe nicht ermitteln")?;
println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string()));
if freelist_before == 0 {
println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig.");
println!();
return Ok(());
}
ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus...");
let freed = db.incremental_vacuum(pages)
.context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?;
ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch...");
db.checkpoint() db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?; .context("Fehler beim finalen WAL-Checkpoint")?;
let recovery_phrase = let freelist_after = db.freelist_count().unwrap_or(0);
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum-Container erfolgreich initialisiert!"); println!("│ ✔ Container erfolgreich kompaktiert! ");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(); println!();
println!("Container: {}", container_path.display()); println!("Freigegebene Seiten: {}", ui::green(&freed.to_string()));
println!("Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); println!("Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string()));
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
println!(); println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display())));
println!();
ui::print_recovery_phrase_card(&recovery_phrase);
Ok(()) Ok(())
} }
@@ -248,11 +406,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let dek = if let Some(phrase) = recovery_key { let (dek, target_slot_id) = if let Some(phrase) = recovery_key {
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
d (d, 0)
} else { } else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?; .context("Fehler beim Einlesen des aktuellen Passworts")?;
@@ -268,16 +426,22 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort..."); ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params) let mut found = None;
.context("Schlüsselableitung fehlgeschlagen")?; for slot in &meta.slots {
if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(
&old_kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
found = Some((d, slot.slot_id));
break;
}
}
}
unwrap_dek( found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
&old_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
}; };
println!(); println!();
@@ -306,7 +470,8 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?; wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?;
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus..."); ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
db.update_meta_keys( db.update_slot_keys(
target_slot_id,
&new_salt, &new_salt,
&new_params, &new_params,
&new_wrapped_dek, &new_wrapped_dek,
@@ -324,6 +489,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(); println!();
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Vault Slot: {}", target_slot_id);
println!(" • KDF: Argon2id mit frischem Salt"); println!(" • KDF: Argon2id mit frischem Salt");
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)"); println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
println!(); println!();
@@ -425,12 +591,23 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?; let mut found = None;
let dek = unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) for slot in &meta.slots {
.map_err(|_| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some((dek, slot.slot_id));
break;
}
}
}
let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?; let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
}
ui::print_recovery_phrase_card(&phrase); ui::print_recovery_phrase_card(&phrase);
Ok(()) Ok(())
@@ -453,13 +630,23 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
let dek = if !password.trim().is_empty() { let dek = if !password.trim().is_empty() {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?; let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
let meta = db.read_meta().context("Konnte Header nicht lesen")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?;
let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params)?;
match unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) { let mut found = None;
Ok(d) => { for slot in &meta.slots {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some(d);
break;
}
}
}
match found {
Some(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("")); println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
Some(d) Some(d)
} }
Err(_) => { None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
None None
} }
@@ -483,8 +670,11 @@ async fn run() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Init { path } => { Commands::Init { path, with_hidden } => {
handle_init(&path)?; handle_init(&path, with_hidden)?;
}
Commands::Compact { path, pages } => {
handle_compact(&path, pages)?;
} }
Commands::Mount { Commands::Mount {
path, path,
+53 -15
View File
@@ -107,33 +107,56 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
let (dek, version) = match auth { let (dek, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => { ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params) let mut unwrapped = None;
.context("Schlüsselableitung fehlgeschlagen")?;
ui::step(3, 4, "🔓", "Entschlüssele DEK via AES-256-GCM..."); for slot in &meta.slots {
let dek = unwrap_dek( if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) {
&kek, if let Ok(dek) = unwrap_dek(
&meta.wrapped_dek, &kek,
&meta.header_nonce, &slot.wrapped_dek,
&meta.header_tag, &slot.header_nonce,
) &slot.header_tag,
.context("Ungültiges Master-Passwort oder Container beschädigt")?; ) {
(dek, meta.version) unwrapped = Some((dek, slot.version, slot.slot_id));
break;
}
}
}
match unwrapped {
Some((dek, ver, slot_id)) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id)
}
None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt");
}
}
} }
ContainerAuth::RecoveryKey(ref phrase) => { ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
let dek = mnemonic_to_dek(phrase) let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?; .context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
(dek, meta.version)
let vault_id = if db.has_hidden_vault().unwrap_or(false) {
let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty()
};
if is_hidden { 1 } else { 0 }
} else {
0
};
(dek, meta.version, vault_id)
} }
}; };
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield) // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing)
let fs = SanctumFs::with_options(db.clone(), dek, version, anti_leak); let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id);
let last_activity = fs.last_activity(); let last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
@@ -300,6 +323,9 @@ pub async fn mount_container(
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port);
if vault_id == 1 {
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
}
if let Some(secs) = idle_timeout { if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs); println!(" • Auto-Lock: Inaktivität nach {}s", secs);
} }
@@ -350,6 +376,18 @@ pub async fn mount_container(
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
let _ = server_handle.await; let _ = server_handle.await;
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
}
}
}
// SQLite WAL Checkpoint erzwingen // SQLite WAL Checkpoint erzwingen
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout()); let _ = std::io::Write::flush(&mut std::io::stdout());
+23 -1
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::crypto::{ use crate::crypto::{
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION,
}; };
use crate::storage::{ContainerMeta, Database}; use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1; pub const CURRENT_BACKUP_VERSION: u32 = 1;
@@ -79,6 +79,16 @@ impl HeaderBackup {
let mut header_tag = [0u8; 16]; let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes); header_tag.copy_from_slice(&tag_bytes);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
Ok(ContainerMeta { Ok(ContainerMeta {
version: self.container_format_version, version: self.container_format_version,
kdf_salt, kdf_salt,
@@ -86,6 +96,7 @@ impl HeaderBackup {
wrapped_dek, wrapped_dek,
header_nonce, header_nonce,
header_tag, header_tag,
slots: vec![slot0],
}) })
} }
} }
@@ -166,6 +177,16 @@ pub fn restore_header_from_recovery_key(
let (wrapped_dek, header_nonce, header_tag) = let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?; wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?;
let slot0 = SlotMeta {
slot_id: 0,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
let meta = ContainerMeta { let meta = ContainerMeta {
version: FORMAT_VERSION, version: FORMAT_VERSION,
kdf_salt: salt, kdf_salt: salt,
@@ -173,6 +194,7 @@ pub fn restore_header_from_recovery_key(
wrapped_dek, wrapped_dek,
header_nonce, header_nonce,
header_tag, header_tag,
slots: vec![slot0],
}; };
// 4. In Container schreiben // 4. In Container schreiben
+717 -127
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -92,6 +92,14 @@ pub fn red(s: &str) -> String {
} }
} }
pub fn magenta(s: &str) -> String {
if is_vt_enabled() {
format!("\x1b[1;35m{s}\x1b[0m")
} else {
s.to_string()
}
}
/// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...` /// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...`
pub fn step(num: u8, total: u8, icon: &str, msg: &str) { pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
let tag = cyan(&format!("[{}/{}]", num, total)); let tag = cyan(&format!("[{}/{}]", num, total));
+64 -67
View File
@@ -388,12 +388,13 @@ pub struct SanctumFs {
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32,
last_activity: Arc<AtomicU64>, last_activity: Arc<AtomicU64>,
} }
impl SanctumFs { impl SanctumFs {
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self { pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self {
Self::with_options(db, dek, format_version, true) Self::with_vault(db, dek, format_version, true, 0)
} }
pub fn with_options( pub fn with_options(
@@ -401,6 +402,16 @@ impl SanctumFs {
dek: Zeroizing<[u8; 32]>, dek: Zeroizing<[u8; 32]>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
) -> Self {
Self::with_vault(db, dek, format_version, anti_leak, 0)
}
pub fn with_vault(
db: Database,
dek: Zeroizing<[u8; 32]>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self { ) -> Self {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -411,10 +422,15 @@ impl SanctumFs {
dek: Arc::new(dek), dek: Arc::new(dek),
format_version, format_version,
anti_leak, anti_leak,
vault_id,
last_activity: Arc::new(AtomicU64::new(now)), last_activity: Arc::new(AtomicU64::new(now)),
} }
} }
pub fn vault_id(&self) -> u32 {
self.vault_id
}
pub fn last_activity(&self) -> Arc<AtomicU64> { pub fn last_activity(&self) -> Arc<AtomicU64> {
self.last_activity.clone() self.last_activity.clone()
} }
@@ -442,6 +458,33 @@ impl SanctumFs {
None => ("", trimmed), None => ("", trimmed),
} }
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>, FsError> {
self.db
.list_children_in_vault(parent_id, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| {
error!("Fehler beim Erstellen des Knotens '{}': {e}", name);
FsError::GeneralFailure
})
}
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
} }
impl DavFileSystem for SanctumFs { impl DavFileSystem for SanctumFs {
@@ -473,10 +516,7 @@ impl DavFileSystem for SanctumFs {
self.touch(); self.touch();
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options); debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
let existing_node = self let existing_node = self.resolve_path(&path_str)?;
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?;
let node = match existing_node { let node = match existing_node {
Some(n) => { Some(n) => {
@@ -511,21 +551,14 @@ impl DavFileSystem for SanctumFs {
None => { None => {
if options.create || options.create_new { if options.create || options.create_new {
let parent = self let parent = self
.db .resolve_path(parent_path)?
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !parent.is_dir { if !parent.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
self.db self.create_node(parent.id, file_name, false)?
.create_node(parent.id, file_name, false)
.map_err(|e| {
error!("Fehler beim Erstellen der Datei: {e}");
FsError::GeneralFailure
})?
} else { } else {
return Err(FsError::NotFound); return Err(FsError::NotFound);
} }
@@ -552,19 +585,14 @@ impl DavFileSystem for SanctumFs {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !node.is_dir { if !node.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
let children = self let children = self.list_children(node.id)?;
.db
.list_children(node.id)
.map_err(|_| FsError::GeneralFailure)?;
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
.into_iter() .into_iter()
@@ -593,9 +621,7 @@ impl DavFileSystem for SanctumFs {
self.touch(); self.touch();
} }
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let meta = SanctumMetaData { let meta = SanctumMetaData {
@@ -623,28 +649,19 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
if self if self.resolve_path(&path_str)?.is_some() {
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.is_some()
{
return Err(FsError::Exists); return Err(FsError::Exists);
} }
let parent = self let parent = self
.db .resolve_path(parent_path)?
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !parent.is_dir { if !parent.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
self.db self.create_node(parent.id, dir_name, true)?;
.create_node(parent.id, dir_name, true)
.map_err(|_| FsError::GeneralFailure)?;
Ok(()) Ok(())
}) })
@@ -655,15 +672,14 @@ impl DavFileSystem for SanctumFs {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !node.is_dir { if !node.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
if node.id == 1 { let root_id = Database::get_root_node_id_for_vault(self.vault_id);
if node.id == root_id {
// Root-Verzeichnis darf nicht gelöscht werden // Root-Verzeichnis darf nicht gelöscht werden
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -681,9 +697,7 @@ impl DavFileSystem for SanctumFs {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if node.is_dir { if node.is_dir {
@@ -709,9 +723,7 @@ impl DavFileSystem for SanctumFs {
let to_str = Self::path_to_str(to); let to_str = Self::path_to_str(to);
let node = self let node = self
.db .resolve_path(&from_str)?
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
@@ -721,9 +733,7 @@ impl DavFileSystem for SanctumFs {
} }
let to_parent = self let to_parent = self
.db .resolve_path(to_parent_path)?
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !to_parent.is_dir { if !to_parent.is_dir {
@@ -731,11 +741,7 @@ impl DavFileSystem for SanctumFs {
} }
// Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen // Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen
if let Some(dest) = self if let Some(dest) = self.resolve_path(&to_str)? {
.db
.resolve_path(&to_str)
.map_err(|_| FsError::GeneralFailure)?
{
if dest.is_dir { if dest.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -744,9 +750,7 @@ impl DavFileSystem for SanctumFs {
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
} }
self.db self.rename_node(node.id, to_parent.id, to_name)?;
.rename_node(node.id, to_parent.id, to_name)
.map_err(|_| FsError::GeneralFailure)?;
Ok(()) Ok(())
}) })
@@ -763,9 +767,7 @@ impl DavFileSystem for SanctumFs {
let to_str = Self::path_to_str(to); let to_str = Self::path_to_str(to);
let node = self let node = self
.db .resolve_path(&from_str)?
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if node.is_dir { if node.is_dir {
@@ -779,15 +781,10 @@ impl DavFileSystem for SanctumFs {
} }
let to_parent = self let to_parent = self
.db .resolve_path(to_parent_path)?
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let dest_node = self let dest_node = self.create_node(to_parent.id, to_name, false)?;
.db
.create_node(to_parent.id, to_name, false)
.map_err(|_| FsError::GeneralFailure)?;
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!) // Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
let total_chunks = if node.size == 0 { let total_chunks = if node.size == 0 {
+184
View File
@@ -609,5 +609,189 @@ async fn test_anti_leak_and_inactivity_shield() {
let _ = std::fs::remove_file(&container_path); 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);
}