Compare commits
3
Commits
99813ae2a3
...
7ed0f51fb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed0f51fb1 | ||
|
|
dae0b3b7c3 | ||
|
|
541190cff4 |
@@ -5,6 +5,28 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
|
||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [0.3.1] - 2026-09-10
|
||||
|
||||
### Added
|
||||
- **Human Factors & OpSec-UX (HF-01 bis HF-04)**:
|
||||
- Maskierte interaktive Notfallschlüssel-Eingabe: `--recovery-key` ohne CLI-Argument liest die 24 BIP-39-Wörter via `rpassword` ein, ohne Klartextspuren in der PowerShell-Historie (`ConsoleHost_history.txt`) zu hinterlassen.
|
||||
- BIP-39 Resilienz & Tippfehler-Diagnose: Automatische String-Normalisierung (Kleinschreibung, Whitespace- und Satzzeichenbereinigung) sowie Levenshtein-Kandidatenvorschläge ($\le 2$ Distanz) bei Eingabefehlern.
|
||||
- Stealth-Mount Modus (`--stealth` / `-s`): Vollständig lautloser Betrieb ohne Banner, Token-Ausgabe, Mount-Pfade oder Box-Rahmen.
|
||||
- VFS Carrier-Schutz im Decoy-Vault: Umfassender Lösch-, Schreib-, Truncate- und Umbenennungsschutz der Alibi-Trägerdatei (`FsError::Forbidden`) bei Zugriff über Slot 0.
|
||||
- **Chaos Engineering & Crash-Resilienz (CHAOS-01 bis CHAOS-03)**:
|
||||
- Transaktionale Crash-Consistency (CHAOS-01): Bündelung von Chunk-Write und Dateigrößen-Aktualisierung (`write_chunk_and_update_size`) in einer einzigen atomaren SQLite-Transaktion (`tx.commit()`) für vollständige Resilienz gegen plötzlichen Stromausfall.
|
||||
- Path Fuzzing & Injection Guard (CHAOS-02): Strikte Validierung aller VFS-Pfade und Knotennamen gegen Null-Bytes (`\0`) und ASCII-Steuerzeichen ($< 0x20$).
|
||||
- Fault Recovery bei Disk Full (CHAOS-03): Sofortige Invalidierung des In-Memory-Chunk-Caches bei Schreibfehlern zur Vermeidung kaskadierender Drop-Panics bei vollem Zieldatenträger.
|
||||
- **DFIR & Windows Host-Memory Hardening**:
|
||||
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
|
||||
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
|
||||
|
||||
### Changed
|
||||
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
|
||||
- Beseitigung verräterischer Konsolenausgaben (magenta hervorgehobene Hinweise auf Hidden Vault / Slot 1) und Harmonisierung des Explorer-Verhaltens für beide Safes.
|
||||
- Vereinheitlichung der Ausgaben von `sanctum verify` und `sanctum recovery-key` zur Vermeidung von Rückschlüssen auf vorhandene Hidden-Vault-Slots.
|
||||
- Interaktiver Assistent für `sanctum restore-header` bei Aufruf ohne CLI-Parameter.
|
||||
|
||||
## [0.3.0] - 2026-09-09
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
|
||||
@@ -94,9 +94,15 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
|
||||
|
||||
# Mit Inaktivitäts-Timeout (in Sekunden):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
|
||||
|
||||
# Lautloser Stealth-Modus (keine Banner/Token-Ausgabe, kein Explorer-Auto-Open):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --stealth
|
||||
|
||||
# Explorer-Auto-Open gezielt unterdrücken (maximale ShellBag-OpSec):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
|
||||
```
|
||||
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
|
||||
- **Windows Explorer**: Das gemountete Laufwerk wird automatisch im Explorer geöffnet.
|
||||
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
|
||||
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
|
||||
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
|
||||
|
||||
@@ -169,7 +175,9 @@ Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
||||
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts.
|
||||
3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab.
|
||||
4. **RAM-Paging-Schutz (`VirtualLock`)**: Verriegelt sensible Schlüsselstrukturen (`DEK`) im physischen RAM, um das Auslagern in `pagefile.sys` oder `swapfile.sys` durch den Windows Memory Manager zu verhindern.
|
||||
5. **DFIR ShellBag-Schutz**: Unterdrückt das automatische Öffnen des Windows Explorers für den Hidden Vault, um zu verhindern, dass vertrauliche Ordnernamen persistent in `UsrClass.dat` protokolliert werden.
|
||||
5. **ShellBag & Forensik-OpSec**: Über die Optionen `--no-open` oder `--stealth` kann das automatische Öffnen des Windows Explorers vollständig unterdrückt werden, um zu verhindern, dass Ordnernamen persistent im ShellBag-Cache (`UsrClass.dat`) protokolliert werden.
|
||||
6. **VFS Carrier-Schutz**: Die im Decoy-Tresor sichtbare Alibi-Trägerdatei wird im VFS gegen versehentliches Löschen, Überschreiben oder Umbenennen geschützt.
|
||||
7. **Power-Loss Crash-Consistency**: Atomare Bündelung von Block-Schreiboperationen und Metadaten-Größenaktualisierungen in einer SQLite-Transaktion verhindert Datenkorruption bei plötzlichem Stromausfall.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,7 +209,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
||||
```
|
||||
|
||||
Erzeugt:
|
||||
- `dist/sanctum-v0.3.0-windows-x86_64.zip`
|
||||
- `dist/sanctum-v0.3.1-windows-x86_64.zip`
|
||||
- `dist/SHA256SUMS.txt`
|
||||
|
||||
---
|
||||
|
||||
+139
-9
@@ -320,16 +320,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||
Ok(mnemonic.to_string())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let cleaned = phrase
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
pub fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
let a_chars: Vec<char> = a.chars().collect();
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let (m, n) = (a_chars.len(), b_chars.len());
|
||||
|
||||
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
|
||||
let mut dp = vec![vec![0usize; n + 1]; m + 1];
|
||||
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();
|
||||
if entropy.len() != 32 {
|
||||
@@ -625,6 +730,31 @@ mod tests {
|
||||
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]
|
||||
fn test_hidden_node_name_encryption_and_dummy_slot() {
|
||||
let dek = generate_dek();
|
||||
|
||||
+83
-26
@@ -76,8 +76,10 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
|
||||
#[arg(long)]
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage).
|
||||
/// 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>,
|
||||
|
||||
/// Ö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)
|
||||
#[arg(long, default_value_t = false)]
|
||||
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
|
||||
@@ -114,8 +120,9 @@ enum Commands {
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort)
|
||||
#[arg(long)]
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort).
|
||||
/// 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>,
|
||||
},
|
||||
|
||||
@@ -162,8 +169,9 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
header_file: Option<PathBuf>,
|
||||
|
||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
||||
#[arg(long)]
|
||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort.
|
||||
/// 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>,
|
||||
|
||||
/// 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(
|
||||
container_path: &Path,
|
||||
with_hidden: bool,
|
||||
@@ -506,8 +527,13 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
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...");
|
||||
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!");
|
||||
(d, 0, None, None)
|
||||
} else {
|
||||
@@ -717,7 +743,37 @@ fn handle_restore_header(
|
||||
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
|
||||
println!();
|
||||
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);
|
||||
let new_password = Zeroizing::new(
|
||||
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...");
|
||||
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));
|
||||
println!();
|
||||
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!();
|
||||
|
||||
@@ -774,12 +828,8 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
||||
.authenticate(&password)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||
let dek = keys.dek().clone();
|
||||
let slot_id = keys.slot_id();
|
||||
|
||||
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(())
|
||||
@@ -807,9 +857,7 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
||||
|
||||
match meta.authenticate(&password) {
|
||||
Some(keys) => {
|
||||
let slot_id = keys.slot_id();
|
||||
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));
|
||||
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"));
|
||||
Some(keys.dek().clone())
|
||||
}
|
||||
None => {
|
||||
@@ -863,21 +911,29 @@ async fn run() -> Result<()> {
|
||||
idle_timeout,
|
||||
no_screen_lock,
|
||||
no_anti_leak,
|
||||
stealth,
|
||||
} => {
|
||||
let drive_char = match drive {
|
||||
Some(ref d) => parse_drive_letter(d)?,
|
||||
None => {
|
||||
let auto_drive = sanctum::windows::find_next_available_drive()?;
|
||||
println!(
|
||||
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
|
||||
ui::cyan("ℹ"),
|
||||
auto_drive
|
||||
);
|
||||
if !stealth {
|
||||
println!(
|
||||
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
|
||||
ui::cyan("ℹ"),
|
||||
auto_drive
|
||||
);
|
||||
}
|
||||
auto_drive
|
||||
}
|
||||
};
|
||||
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 {
|
||||
let prompt_text = format!(
|
||||
"Master-Passwort für Container '{}' eingeben: ",
|
||||
@@ -890,8 +946,8 @@ async fn run() -> Result<()> {
|
||||
ContainerAuth::Password(password)
|
||||
};
|
||||
|
||||
let open_explorer = !no_open;
|
||||
let enable_tray = !no_tray;
|
||||
let open_explorer = !no_open && !stealth;
|
||||
let enable_tray = !no_tray && !stealth;
|
||||
let lock_on_screen_lock = !no_screen_lock;
|
||||
let anti_leak = !no_anti_leak;
|
||||
mount_container(
|
||||
@@ -904,6 +960,7 @@ async fn run() -> Result<()> {
|
||||
idle_timeout,
|
||||
lock_on_screen_lock,
|
||||
anti_leak,
|
||||
stealth,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
+124
-94
@@ -120,6 +120,7 @@ pub async fn mount_container(
|
||||
idle_timeout: Option<u64>,
|
||||
lock_on_screen_lock: bool,
|
||||
anti_leak: bool,
|
||||
stealth: bool,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
|
||||
@@ -130,8 +131,10 @@ pub async fn mount_container(
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||
if !stealth {
|
||||
println!();
|
||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||
}
|
||||
let db = Database::open(container_path)
|
||||
.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 {
|
||||
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) {
|
||||
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 ver = keys.version();
|
||||
let c_dek = keys.carrier_dek();
|
||||
@@ -157,10 +164,14 @@ pub async fn mount_container(
|
||||
}
|
||||
}
|
||||
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)
|
||||
.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
|
||||
let is_hidden = {
|
||||
@@ -212,15 +223,17 @@ pub async fn mount_container(
|
||||
let bound_addr = listener.local_addr()?;
|
||||
let bound_port = bound_addr.port();
|
||||
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"🌐",
|
||||
&format!(
|
||||
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
|
||||
bound_port, drive_str
|
||||
),
|
||||
);
|
||||
if !stealth {
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"🌐",
|
||||
&format!(
|
||||
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
|
||||
bound_port, drive_str
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
@@ -239,17 +252,9 @@ pub async fn mount_container(
|
||||
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 vault_id == 1 {
|
||||
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);
|
||||
}
|
||||
let _ = crate::windows::open_in_explorer(drive_letter);
|
||||
}
|
||||
|
||||
// System-Tray Initialisierung
|
||||
@@ -324,32 +329,33 @@ pub async fn mount_container(
|
||||
None
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
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 vault_id == 1 {
|
||||
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
|
||||
if stealth {
|
||||
println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
|
||||
} else {
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
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)
|
||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
@@ -359,64 +365,88 @@ pub async fn mount_container(
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
let _ = res;
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = console_close_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = tray_shutdown_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = session_lock_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = idle_shutdown_rx.recv() => {
|
||||
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})"))),
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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})")));
|
||||
if stealth {
|
||||
let _ = unmount_drive(drive_letter);
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
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 {
|
||||
println!("{}", ui::green("OK"));
|
||||
}
|
||||
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
println!();
|
||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
||||
println!();
|
||||
// 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
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,24 @@ impl Database {
|
||||
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).
|
||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -1182,6 +1200,43 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar
|
||||
/// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01).
|
||||
pub fn write_chunk_and_update_size(
|
||||
&self,
|
||||
node_id: i64,
|
||||
chunk_index: u32,
|
||||
nonce: &[u8; 12],
|
||||
tag: &[u8; 16],
|
||||
ciphertext: &[u8],
|
||||
new_size: u64,
|
||||
modified_at: u64,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
|
||||
nonce = excluded.nonce,
|
||||
tag = excluded.tag,
|
||||
ciphertext = excluded.ciphertext",
|
||||
params![
|
||||
node_id,
|
||||
chunk_index,
|
||||
nonce.as_slice(),
|
||||
tag.as_slice(),
|
||||
ciphertext,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
|
||||
params![new_size as i64, modified_at as i64, node_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
||||
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
||||
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
|
||||
@@ -1691,4 +1746,48 @@ mod tests {
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_chunk_write_and_size_update() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
||||
[1u8; 16],
|
||||
KdfParams::default(),
|
||||
vec![2u8; 40],
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
|
||||
let file = db.create_node(1, "crash_test.bin", false).unwrap();
|
||||
assert_eq!(file.size, 0);
|
||||
|
||||
let chunk_nonce = [5u8; 12];
|
||||
let chunk_tag = [6u8; 16];
|
||||
let ciphertext = vec![7u8; 1024];
|
||||
let new_size = 1024u64;
|
||||
let modified_at = 2000000u64;
|
||||
|
||||
db.write_chunk_and_update_size(
|
||||
file.id,
|
||||
0,
|
||||
&chunk_nonce,
|
||||
&chunk_tag,
|
||||
&ciphertext,
|
||||
new_size,
|
||||
modified_at,
|
||||
).expect("Atomic write");
|
||||
|
||||
// Chunk verifizieren
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
|
||||
assert_eq!(chunk.ciphertext, ciphertext);
|
||||
assert_eq!(chunk.nonce, chunk_nonce);
|
||||
assert_eq!(chunk.tag, chunk_tag);
|
||||
|
||||
// Inode verifizieren
|
||||
let nodes = db.list_children(1).unwrap();
|
||||
let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap();
|
||||
assert_eq!(updated_file.size, new_size);
|
||||
assert_eq!(updated_file.modified_at, modified_at);
|
||||
}
|
||||
}
|
||||
|
||||
+198
-29
@@ -168,8 +168,15 @@ impl SanctumFile {
|
||||
}
|
||||
|
||||
|
||||
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück.
|
||||
fn flush_cached_chunk(&mut self) -> Result<(), FsError> {
|
||||
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
|
||||
/// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
|
||||
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
|
||||
fn flush_cached_chunk_and_size(&mut self) -> Result<(), FsError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Some((idx, ref data, true)) = self.cached_chunk {
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
|
||||
@@ -177,16 +184,35 @@ impl SanctumFile {
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
|
||||
self.db
|
||||
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext)
|
||||
.map_err(|e| {
|
||||
error!("DB-Fehler beim Schreiben des Chunks: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
if let Err(e) = self.db.write_chunk_and_update_size(
|
||||
self.node_id,
|
||||
idx,
|
||||
&nonce,
|
||||
&tag,
|
||||
&ciphertext,
|
||||
self.file_size,
|
||||
now,
|
||||
) {
|
||||
error!("DB-Fehler beim atomaren Chunk- und Size-Write #{idx}: {e}");
|
||||
// CHAOS-03: Bei I/O- oder Disk-Full-Fehlern den Cache sauber invalidieren,
|
||||
// um Folgefehler und Panic-/Warnungsschleifen beim Drop zu unterbinden!
|
||||
self.cached_chunk = None;
|
||||
return Err(FsError::GeneralFailure);
|
||||
}
|
||||
|
||||
if let Some((_, _, ref mut dirty)) = self.cached_chunk {
|
||||
*dirty = false;
|
||||
}
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
} else if self.meta.size != self.file_size {
|
||||
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
|
||||
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
|
||||
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
|
||||
return Err(FsError::GeneralFailure);
|
||||
}
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -199,7 +225,7 @@ impl SanctumFile {
|
||||
};
|
||||
|
||||
if !is_current {
|
||||
self.flush_cached_chunk()?;
|
||||
self.flush_cached_chunk_and_size()?;
|
||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||
data.zeroize();
|
||||
}
|
||||
@@ -236,17 +262,12 @@ impl SanctumFile {
|
||||
|
||||
impl Drop for SanctumFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.flush_cached_chunk() {
|
||||
if let Err(e) = self.flush_cached_chunk_and_size() {
|
||||
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
|
||||
}
|
||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||
data.zeroize();
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +355,7 @@ impl DavFile for SanctumFile {
|
||||
|
||||
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
|
||||
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
|
||||
self.flush_cached_chunk()?;
|
||||
self.flush_cached_chunk_and_size()?;
|
||||
}
|
||||
|
||||
src = &src[to_write..];
|
||||
@@ -370,19 +391,7 @@ impl DavFile for SanctumFile {
|
||||
fn flush(&mut self) -> FsFuture<'_, ()> {
|
||||
self.touch();
|
||||
Box::pin(async move {
|
||||
self.flush_cached_chunk()?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
self.db
|
||||
.update_node_size_and_time(self.node_id, self.file_size, now)
|
||||
.map_err(|e| {
|
||||
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
self.flush_cached_chunk_and_size()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -446,6 +455,16 @@ impl SanctumFs {
|
||||
let dek_arc = Arc::new(dek);
|
||||
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 {
|
||||
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
|
||||
match CarrierFs::load(
|
||||
@@ -529,6 +548,7 @@ impl SanctumFs {
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
|
||||
validate_path_safety(path)?;
|
||||
self.db
|
||||
.resolve_path_in_vault(path, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
@@ -541,6 +561,7 @@ impl SanctumFs {
|
||||
}
|
||||
|
||||
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
|
||||
validate_path_safety(name)?;
|
||||
self.db
|
||||
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
||||
.map_err(|e| {
|
||||
@@ -550,12 +571,26 @@ impl SanctumFs {
|
||||
}
|
||||
|
||||
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
|
||||
validate_path_safety(new_name)?;
|
||||
self.db
|
||||
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validiert, dass ein Pfad oder Dateiname keine Null-Bytes oder unzulässige Steuerzeichen enthält (CHAOS-02).
|
||||
pub fn validate_path_safety(path: &str) -> Result<(), FsError> {
|
||||
if path.contains('\0') {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
for c in path.chars() {
|
||||
if (c as u32) < 0x20 && c != '\t' {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Drop for SanctumFs {
|
||||
fn drop(&mut self) {
|
||||
crate::windows::unlock_memory(self.dek.as_ptr(), 32);
|
||||
@@ -787,6 +822,13 @@ impl DavFileSystem for SanctumFs {
|
||||
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
|
||||
.delete_node(node.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
@@ -866,6 +908,10 @@ impl DavFileSystem for SanctumFs {
|
||||
if dest.is_dir {
|
||||
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
|
||||
.delete_node(dest.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
@@ -901,6 +947,13 @@ impl DavFileSystem for SanctumFs {
|
||||
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
|
||||
.resolve_path(to_parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
@@ -1124,4 +1177,120 @@ mod tests {
|
||||
let new_time = act_arc.load(Ordering::Relaxed);
|
||||
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)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_safety_rejects_null_bytes_and_control_chars() {
|
||||
let (fs, _dir) = create_test_fs(true);
|
||||
|
||||
// 1. Null-Byte im Pfad
|
||||
let null_path = DavPath::new("/bad\0file.txt");
|
||||
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
|
||||
assert!(validate_path_safety("/bad\0file.txt").is_err());
|
||||
|
||||
// 2. Steuerzeichen < 0x20
|
||||
assert!(validate_path_safety("/bad\x01file.txt").is_err());
|
||||
assert!(validate_path_safety("/bad\rfile.txt").is_err());
|
||||
assert!(validate_path_safety("/bad\nfile.txt").is_err());
|
||||
|
||||
// 3. Gültiger Pfad
|
||||
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
|
||||
assert!(validate_path_safety("/path/to/subfolder/file.pdf").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_atomic_and_cache_invalidation() {
|
||||
let (fs, _dir) = create_test_fs(true);
|
||||
let path = DavPath::new("/atomic_test.bin").unwrap();
|
||||
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create_new = true;
|
||||
let mut file = fs.open(&path, opts).await.unwrap();
|
||||
|
||||
// 1. Schreibe 500 Bytes
|
||||
let payload = Bytes::from(vec![42u8; 500]);
|
||||
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
|
||||
|
||||
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
|
||||
file.flush().await.unwrap();
|
||||
|
||||
let node = fs.resolve_path("/atomic_test.bin").unwrap().unwrap();
|
||||
assert_eq!(node.size, 500);
|
||||
|
||||
// Chunk in DB prüfen
|
||||
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
||||
assert!(!chunk.ciphertext.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user