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
+250 -60
View File
@@ -33,6 +33,21 @@ enum Commands {
/// Pfad zur zu erstellenden .sanctum Containerdatei
#[arg(short, long)]
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
@@ -157,7 +172,7 @@ fn parse_drive_letter(s: &str) -> Result<char> {
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() {
bail!(
"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!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault"));
}
println!();
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if with_hidden {
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() {
bail!("Das Master-Passwort darf nicht leer sein.");
println!();
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: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
Ok(())
}
if password != confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
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!();
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")?;
.context("Konnte Container-Datenbank nicht öffnen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.context("Fehler bei der Schema-Initialisierung")?;
let freelist_before = db.freelist_count()
.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()
.context("Fehler beim finalen WAL-Checkpoint")?;
let recovery_phrase =
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
let freelist_after = db.freelist_count().unwrap_or(0);
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum-Container erfolgreich initialisiert!");
println!("│ ✔ Container erfolgreich kompaktiert! ");
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!("Freigegebene Seiten: {}", ui::green(&freed.to_string()));
println!("Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string()));
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(())
}
@@ -248,11 +406,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path)
.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...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
d
(d, 0)
} else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.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")?;
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)
.context("Schlüsselableitung fehlgeschlagen")?;
let mut found = None;
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(
&old_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
};
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")?;
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_params,
&new_wrapped_dek,
@@ -324,6 +489,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Vault Slot: {}", target_slot_id);
println!(" • KDF: Argon2id mit frischem Salt");
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
println!();
@@ -425,12 +591,23 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
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 dek = unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.map_err(|_| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let mut found = None;
for slot in &meta.slots {
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)?;
if slot_id == 1 {
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
}
ui::print_recovery_phrase_card(&phrase);
Ok(())
@@ -453,13 +630,23 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
let dek = if !password.trim().is_empty() {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
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) {
Ok(d) => {
let mut found = None;
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(""));
Some(d)
}
Err(_) => {
None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
None
}
@@ -483,8 +670,11 @@ async fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => {
handle_init(&path)?;
Commands::Init { path, with_hidden } => {
handle_init(&path, with_hidden)?;
}
Commands::Compact { path, pages } => {
handle_compact(&path, pages)?;
}
Commands::Mount {
path,