feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection

- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
This commit is contained in:
2026-09-10 13:27:01 +02:00
parent 99813ae2a3
commit 541190cff4
5 changed files with 464 additions and 129 deletions
+139 -9
View File
@@ -320,16 +320,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
Ok(mnemonic.to_string()) Ok(mnemonic.to_string())
} }
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. pub fn levenshtein_distance(a: &str, b: &str) -> usize {
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme. let a_chars: Vec<char> = a.chars().collect();
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> { let b_chars: Vec<char> = b.chars().collect();
let cleaned = phrase let (m, n) = (a_chars.len(), b_chars.len());
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned) let mut dp = vec![vec![0usize; n + 1]; m + 1];
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?; for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort.
pub fn suggest_bip39_word(word: &str) -> Option<&'static str> {
let word_list = bip39::Language::English.word_list();
let mut best_match = None;
let mut min_dist = usize::MAX;
for &valid in word_list {
let dist = levenshtein_distance(word, valid);
if dist < min_dist {
min_dist = dist;
best_match = Some(valid);
}
}
// Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2)
if min_dist <= 2 {
best_match
} else {
None
}
}
/// Normalisiert eine eingegebene BIP-39 Phrase:
/// - Wandelt in Kleinbuchstaben um (to_lowercase)
/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen
/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing)
pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
let cleaned: String = phrase
.chars()
.map(|c| {
if c.is_alphabetic() {
c.to_ascii_lowercase()
} else {
' '
}
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let words = normalize_mnemonic_phrase(phrase);
if words.is_empty() {
bail!("Die Notfallphrase darf nicht leer sein.");
}
if words.len() != 24 {
bail!(
"Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.",
words.len()
);
}
let word_list = bip39::Language::English.word_list();
let mut invalid_words = Vec::new();
for (idx, word) in words.iter().enumerate() {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
};
invalid_words.push(msg);
}
}
if !invalid_words.is_empty() {
bail!(
"Ungültige Wörter in der Notfallphrase festgestellt:\n • {}",
invalid_words.join("\n")
);
}
let normalized_phrase = words.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase)
.map_err(|_| {
anyhow::anyhow!(
"Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \
Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort."
)
})?;
let entropy = mnemonic.to_entropy(); let entropy = mnemonic.to_entropy();
if entropy.len() != 32 { if entropy.len() != 32 {
@@ -625,6 +730,31 @@ mod tests {
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_bip39_advanced_normalisation_and_suggestions() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).unwrap();
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
// 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization)
let uppercase_phrase = mnemonic_str.to_uppercase();
let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase");
assert_eq!(*dek, *recovered);
// 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern)
let punctuated = format!("{}.", words.join(", "));
let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated");
assert_eq!(*dek, *recovered_punct);
// 3. Typo-Vorschlag (Levenshtein)
let mut typo_words = words.clone();
typo_words[5] = "abondon"; // Typo for abandon
let typo_phrase = typo_words.join(" ");
let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string();
assert!(err.contains("Wort #6 'abondon' ist ungültig"));
assert!(err.contains("Meinten Sie 'abandon'?"));
}
#[test] #[test]
fn test_hidden_node_name_encryption_and_dummy_slot() { fn test_hidden_node_name_encryption_and_dummy_slot() {
let dek = generate_dek(); let dek = generate_dek();
+83 -26
View File
@@ -76,8 +76,10 @@ enum Commands {
#[arg(long)] #[arg(long)]
port: Option<u16>, port: Option<u16>,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage) /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage).
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel sicher und interaktiv
/// einzugeben (verhindert Klartext-Einträge in der PowerShell-Historie).
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer /// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
@@ -99,6 +101,10 @@ enum Commands {
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini) /// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
no_anti_leak: bool, no_anti_leak: bool,
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
#[arg(short, long, default_value_t = false)]
stealth: bool,
}, },
/// Trennt ein eingebundenes Netzlaufwerk manuell /// Trennt ein eingebundenes Netzlaufwerk manuell
@@ -114,8 +120,9 @@ enum Commands {
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, path: PathBuf,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort) /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort).
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
}, },
@@ -162,8 +169,9 @@ enum Commands {
#[arg(long)] #[arg(long)]
header_file: Option<PathBuf>, header_file: Option<PathBuf>,
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort /// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort.
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0) /// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
@@ -228,6 +236,19 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
} }
} }
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan(""));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der PowerShell-Historie.", ui::dim("OpSec"));
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
.context("Fehler beim Einlesen des Notfallschlüssels")?;
if phrase.trim().is_empty() {
bail!("Der Notfallschlüssel darf nicht leer sein.");
}
Ok(Zeroizing::new(phrase))
}
fn handle_init( fn handle_init(
container_path: &Path, container_path: &Path,
with_hidden: bool, with_hidden: bool,
@@ -506,8 +527,13 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key { let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
let actual_phrase = if phrase.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(phrase.to_string())
};
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(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0, None, None) (d, 0, None, None)
} else { } else {
@@ -717,7 +743,37 @@ fn handle_restore_header(
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!"); ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
println!(); println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")); println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
} else if let Some(key) = recovery_key { } else {
let key_str = if let Some(key) = recovery_key {
if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key.to_string())
}
} else {
// Interaktiver Auswahldialog statt Fehlermeldung
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
println!(" Bitte wählen Sie die gewünschte Wiederherstellungsmethode:");
println!(" [1] Sicherungsdatei verwenden (.sanctum.hdr)");
println!(" [2] 24-Wort BIP-39 Notfallschlüssel interaktiv eingeben");
print!(" Auswahl [1/2]: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut choice = String::new();
std::io::stdin().read_line(&mut choice)?;
match choice.trim() {
"1" => {
print!(" Pfad zur .sanctum.hdr Sicherungsdatei: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut p = String::new();
std::io::stdin().read_line(&mut p)?;
let hdr_path = PathBuf::from(p.trim());
return handle_restore_header(container_path, Some(&hdr_path), None, slot);
}
"2" => prompt_recovery_key_interactive()?,
_ => bail!("Ungültige Auswahl. Wiederherstellung abgebrochen."),
}
};
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot); println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
let new_password = Zeroizing::new( let new_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort festlegen: ") rpassword::prompt_password("Neues Master-Passwort festlegen: ")
@@ -738,12 +794,10 @@ fn handle_restore_header(
} }
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_slot_from_recovery_key(container_path, key, &new_password, slot)?; restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?;
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot)); ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
println!(); println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")); println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
} else {
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
} }
println!(); println!();
@@ -774,12 +828,8 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
.authenticate(&password) .authenticate(&password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let dek = keys.dek().clone(); let dek = keys.dek().clone();
let slot_id = keys.slot_id();
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(())
@@ -807,9 +857,7 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
match meta.authenticate(&password) { match meta.authenticate(&password) {
Some(keys) => { Some(keys) => {
let slot_id = keys.slot_id(); println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""), ui::cyan(vault_desc));
Some(keys.dek().clone()) Some(keys.dek().clone())
} }
None => { None => {
@@ -863,21 +911,29 @@ async fn run() -> Result<()> {
idle_timeout, idle_timeout,
no_screen_lock, no_screen_lock,
no_anti_leak, no_anti_leak,
stealth,
} => { } => {
let drive_char = match drive { let drive_char = match drive {
Some(ref d) => parse_drive_letter(d)?, Some(ref d) => parse_drive_letter(d)?,
None => { None => {
let auto_drive = sanctum::windows::find_next_available_drive()?; let auto_drive = sanctum::windows::find_next_available_drive()?;
println!( if !stealth {
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:", println!(
ui::cyan(""), " {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
auto_drive ui::cyan(""),
); auto_drive
);
}
auto_drive auto_drive
} }
}; };
let auth = if let Some(key) = recovery_key { let auth = if let Some(key) = recovery_key {
ContainerAuth::RecoveryKey(Zeroizing::new(key)) let actual_key = if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key)
};
ContainerAuth::RecoveryKey(actual_key)
} else { } else {
let prompt_text = format!( let prompt_text = format!(
"Master-Passwort für Container '{}' eingeben: ", "Master-Passwort für Container '{}' eingeben: ",
@@ -890,8 +946,8 @@ async fn run() -> Result<()> {
ContainerAuth::Password(password) ContainerAuth::Password(password)
}; };
let open_explorer = !no_open; let open_explorer = !no_open && !stealth;
let enable_tray = !no_tray; let enable_tray = !no_tray && !stealth;
let lock_on_screen_lock = !no_screen_lock; let lock_on_screen_lock = !no_screen_lock;
let anti_leak = !no_anti_leak; let anti_leak = !no_anti_leak;
mount_container( mount_container(
@@ -904,6 +960,7 @@ async fn run() -> Result<()> {
idle_timeout, idle_timeout,
lock_on_screen_lock, lock_on_screen_lock,
anti_leak, anti_leak,
stealth,
) )
.await?; .await?;
} }
+124 -94
View File
@@ -120,6 +120,7 @@ pub async fn mount_container(
idle_timeout: Option<u64>, idle_timeout: Option<u64>,
lock_on_screen_lock: bool, lock_on_screen_lock: bool,
anti_leak: bool, anti_leak: bool,
stealth: bool,
) -> Result<()> { ) -> Result<()> {
let drive_str = format_drive(drive_letter); let drive_str = format_drive(drive_letter);
@@ -130,8 +131,10 @@ pub async fn mount_container(
); );
} }
println!(); if !stealth {
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
@@ -141,10 +144,14 @@ pub async fn mount_container(
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth { let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => { ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); if !stealth {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
}
match meta.authenticate(password) { match meta.authenticate(password) {
Some(keys) => { Some(keys) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
}
let vault_id = keys.slot_id(); let vault_id = keys.slot_id();
let ver = keys.version(); let ver = keys.version();
let c_dek = keys.carrier_dek(); let c_dek = keys.carrier_dek();
@@ -157,10 +164,14 @@ pub async fn mount_container(
} }
} }
ContainerAuth::RecoveryKey(ref phrase) => { ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); if !stealth {
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!"); if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann // Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
let is_hidden = { let is_hidden = {
@@ -212,15 +223,17 @@ pub async fn mount_container(
let bound_addr = listener.local_addr()?; let bound_addr = listener.local_addr()?;
let bound_port = bound_addr.port(); let bound_port = bound_addr.port();
ui::step( if !stealth {
4, ui::step(
4, 4,
"🌐", 4,
&format!( "🌐",
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", &format!(
bound_port, drive_str "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
), bound_port, drive_str
); ),
);
}
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -239,17 +252,9 @@ pub async fn mount_container(
return Err(e); return Err(e);
} }
// Optional automatisch im Windows Explorer öffnen (mit DFIR ShellBag-Schutz für Hidden Vault) // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
if open_explorer { if open_explorer {
if vault_id == 1 { let _ = crate::windows::open_in_explorer(drive_letter);
println!(
" {} {}",
ui::yellow("[!]"),
ui::dim("OpSec-Schutz: Explorer-Auto-Open für Hidden Vault unterdrückt (verhindert persistente ShellBag-Spuren in UsrClass.dat).")
);
} else {
let _ = crate::windows::open_in_explorer(drive_letter);
}
} }
// System-Tray Initialisierung // System-Tray Initialisierung
@@ -324,32 +329,33 @@ pub async fn mount_container(
None None
}; };
println!(); if stealth {
println!("┌─────────────────────────────────────────────────────────────┐"); println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
println!("│ ✔ Sanctum Container erfolgreich gemountet │"); } else {
println!("└─────────────────────────────────────────────────────────────┘"); println!();
println!(); println!("┌─────────────────────────────────────────────────────────────┐");
println!(" Container: {}", container_path.display()); println!(" ✔ Sanctum Container erfolgreich gemountet │");
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!("└─────────────────────────────────────────────────────────────┘");
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token); println!();
if vault_id == 1 { println!(" • Container: {}", container_path.display());
println!("Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)")); println!("Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
}
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!();
} }
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
}
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!();
// Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT) // Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1); let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
@@ -359,64 +365,88 @@ pub async fn mount_container(
tokio::select! { tokio::select! {
res = tokio::signal::ctrl_c() => { res = tokio::signal::ctrl_c() => {
let _ = res; let _ = res;
println!(); if !stealth {
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
} }
_ = console_close_rx.recv() => { _ = console_close_rx.recv() => {
println!(); if !stealth {
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]")); println!();
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
}
} }
_ = tray_shutdown_rx.recv() => { _ = tray_shutdown_rx.recv() => {
println!(); if !stealth {
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
}
} }
_ = session_lock_rx.recv() => { _ = session_lock_rx.recv() => {
println!(); if !stealth {
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
} }
_ = idle_shutdown_rx.recv() => { _ = idle_shutdown_rx.recv() => {
println!(); if !stealth {
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
} println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
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 if stealth {
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); let _ = unmount_drive(drive_letter);
let _ = std::io::Write::flush(&mut std::io::stdout()); let _ = shutdown_tx.send(true);
if let Err(e) = db.checkpoint() { let _ = server_handle.await;
println!("{}", ui::red(&format!("Fehler ({e})"))); if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
let _ = db.incremental_vacuum(None);
}
}
let _ = db.checkpoint();
println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str);
} else { } else {
println!("{}", ui::green("OK")); print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
} let _ = std::io::Write::flush(&mut std::io::stdout());
println!(); // Automatisches Unmount
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("")); if let Err(e) = unmount_drive(drive_letter) {
println!(); println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
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
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout());
if let Err(e) = db.checkpoint() {
println!("{}", ui::red(&format!("Fehler ({e})")));
} else {
println!("{}", ui::green("OK"));
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!();
}
Ok(()) Ok(())
} }
+18
View File
@@ -281,6 +281,24 @@ impl Database {
Ok(id) Ok(id)
} }
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
if node_id == ancestor_id {
return Ok(true);
}
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(id) AS (
SELECT id FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
)?;
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
Ok(exists.is_some())
}
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding). /// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
+100
View File
@@ -446,6 +446,16 @@ impl SanctumFs {
let dek_arc = Arc::new(dek); let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new); let carrier_dek_arc = carrier_dek.map(Arc::new);
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
let carrier_node_id = carrier_node_id.or_else(|| {
if vault_id == 0 {
db.find_carrier_node_id().ok().flatten()
} else {
None
}
});
let carrier_fs = if vault_id == 1 { let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) { if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
match CarrierFs::load( match CarrierFs::load(
@@ -787,6 +797,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
if let Some(carrier_id) = self.carrier_node_id {
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
return Err(FsError::Forbidden);
}
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -866,6 +883,10 @@ impl DavFileSystem for SanctumFs {
if dest.is_dir { if dest.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten!
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(dest.id) .delete_node(dest.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -901,6 +922,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
if let Some(dest) = self.resolve_path(&to_str)? {
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
}
let to_parent = self let to_parent = self
.resolve_path(to_parent_path)? .resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -1124,4 +1152,76 @@ mod tests {
let new_time = act_arc.load(Ordering::Relaxed); let new_time = act_arc.load(Ordering::Relaxed);
assert!(new_time > 1000); assert!(new_time > 1000);
} }
#[tokio::test]
async fn test_carrier_protection_in_decoy_vault() {
let (fs, _dir) = create_test_fs(true);
// Erstelle eine Carrier-Datei "system_backup.dat"
let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap();
let carrier_id = carrier_node.id;
// Erstelle FS mit bekanntem carrier_node_id
let protected_fs = SanctumFs::with_carrier(
fs.db.clone(),
(*fs.dek).clone(),
None,
Some(carrier_id),
FORMAT_VERSION,
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// 1. Lesen muss erlaubt sein
let mut read_opts = OpenOptions::default();
read_opts.read = true;
assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok());
// 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden)
let mut write_opts = OpenOptions::default();
write_opts.write = true;
assert!(matches!(
protected_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
));
let mut trunc_opts = OpenOptions::default();
trunc_opts.truncate = true;
assert!(matches!(
protected_fs.open(&carrier_path, trunc_opts).await,
Err(FsError::Forbidden)
));
// 3. Löschen der Carrier-Datei muss verboten sein
assert!(matches!(
protected_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
));
// 4. Umbenennen der Carrier-Datei muss verboten sein
let rename_target = DavPath::new("/renamed_backup.dat").unwrap();
assert!(matches!(
protected_fs.rename(&carrier_path, &rename_target).await,
Err(FsError::Forbidden)
));
// 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein
let other_path = DavPath::new("/other.txt").unwrap();
let mut other_opts = OpenOptions::default();
other_opts.write = true;
other_opts.create_new = true;
protected_fs.open(&other_path, other_opts).await.unwrap();
assert!(matches!(
protected_fs.rename(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
// 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein
assert!(matches!(
protected_fs.copy(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
}
} }