13 Commits
Author SHA1 Message Date
harald 7ed0f51fb1 chore(release): bump version to 0.3.1 and update release notes
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 14:08:48 +02:00
harald dae0b3b7c3 feat(resilience): implement CHAOS-01 to CHAOS-03 crash consistency and input guards
- CHAOS-01: write chunk and update node size atomically via write_chunk_and_update_size in a single SQLite transaction
- CHAOS-02: reject null-bytes and ASCII control characters in VFS paths and node names via validate_path_safety
- CHAOS-03: invalidate in-memory chunk cache on I/O and disk-full errors to prevent drop failure cascades
2026-09-10 13:58:39 +02:00
harald 541190cff4 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
2026-09-10 13:27:01 +02:00
harald 99813ae2a3 feat(security): add Win32 VirtualLock memory protection, CFA error diagnostics, and ShellBag OpSec 2026-09-10 12:27:57 +02:00
harald 90cf4927af fix(release): normalize file paths for curl upload and upload standalone binary 2026-09-09 22:21:34 +02:00
harald abf395627a chore: release v0.3.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-09 22:00:04 +02:00
harald fcd59dfe68 security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection 2026-09-09 21:54:05 +02:00
harald a8fdfc674e fix(sre): implement PRR-01 stale mount self-healing and PRR-02 win32 console close handler 2026-09-09 21:45:35 +02:00
harald 70d03df676 fix(security): implement P3 high-entropy carrier default and P5 manifest capacity guard 2026-09-09 21:33:01 +02:00
harald 771c08ff0f fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization 2026-09-09 21:24:07 +02:00
harald 98bfac718f fix(core): resolve all 12 adversarial review findings
- Zeroize passwords in CLI prompts, handlers, and mount authentication
- Preserve carrier_node_id when recovering Slot 0 via recovery key
- Add --slot parameter to restore-header for targeted slot recovery
- Implement online_backup and restore_from_backup using SQLite Online Backup API
- Expose 'sanctum backup' and 'sanctum restore' CLI subcommands
- Fix inactivity auto-lock by removing touch() from PROPFIND metadata/read_dir
- Support O_APPEND by setting file cursor to file size on handle creation
- Prevent data loss by implementing Drop for CarrierFile to flush dirty blocks
- Optimize CSPRNG padding to only fill unwritten slack space
- Batch carrier initialization in 500-block transactions to prevent UI/CLI freeze
- Enforce 64-byte savings threshold for LZ4 compression
- Add WebClient service diagnostic hint for Windows net use mount errors
- Add unit and integration tests covering all new features
2026-09-09 21:02:21 +02:00
harald 030ce6a1e5 feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability) 2026-09-09 20:22:00 +02:00
harald b8e4dcb614 feat(security): implement Phase 1 of Plausible Deniability hardening 2026-09-09 19:55:34 +02:00
17 changed files with 4452 additions and 524 deletions
+52
View File
@@ -5,6 +5,58 @@ 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/) 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/). 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
- **Modell A: Steganografischer Carrier & Plausible Deniability Phase 2**:
- Zwei-Schichten-AEAD (`DEK_0` + `DEK_1`) zur vollständigen Abwehr von Chunks-Accounting-Angriffen.
- Physische Dateigrößen-Invarianz: Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
- Virtuelles Carrier-Dateisystem (`CarrierFs`) mit reserviertem Block 0 für Manifest und Block-Allokationsbitmap.
- Schreib-, Lösch- und Umbenennungsschutz der Alibi-Trägerdatei im Decoy-Mount (`FsError::Forbidden`).
- **CLI & Disaster Recovery Erweiterungen**:
- `sanctum backup` & `sanctum restore`: Dedizierte CLI-Befehle für konsistente Online-Sicherungen.
- `sanctum restore-header --slot <0|1>`: Gezielte Wiederherstellung für Decoy- oder Hidden-Vault-Header.
- `sanctum init --carrier-file <NAME> --carrier-size <MB>`: Frei konfigurierbare Alibi-Trägerdatei mit forensischen Sicherheitsprüfungen.
- **SRE & Production Resilience**:
- Nativer Win32 `SetConsoleCtrlHandler`: Synchrones Aushängen und WAL-Checkpointing bei Schließen des Konsolenfensters (`CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, `CTRL_SHUTDOWN_EVENT`).
- Stale Mount Self-Healing: Automatische Erkennung und Bereinigung verwaister Windows-Netzlaufwerke (Systemfehler 85).
- Automatische Erkennung und Diagnosehilfe für den Windows `WebClient`-Dienst (Fehler 67).
- **Red Team & Threat Model Hardening**:
- WebDAV Loopback Protection: Dynamisches 128-Bit Session-Token im URL-Pfad (Schutz vor unprivilegierten lokalen Prozessen & CSRF).
- Anti-DNS-Rebinding & Anti-Spoofing: Strikte Fail-Closed Host-Header-Validierung.
- Anti-Slowloris & Connection Limiting: Begrenzung auf maximal 64 gleichzeitige WebDAV-Verbindungen und 15s Header-Read-Timeout.
- Decompression-Bomb-Schutz: Strikter 1-MB-Größen-Guard vor LZ4-Dekomprimierung zur Vermeidung von Speichererschöpfung (CWE-400).
- Verzeichnis-Hijacking-Schutz: Dynamische Bindung verschlüsselter Knotennamen an die `parent_id` via AEAD-AAD.
- Vollständige RAM-Zeroization: Schutz sensibler Daten im Heap via `zeroize::Zeroize` bei Puffer-Swaps und `Drop`.
### Changed
- Standardname der Trägerdatei auf forensisch plausiblen Typ `system_backup.dat` geändert.
- Heap- und Schreiboptimierung: Reduzierung der CSPRNG-Padding-Generierung auf den tatsächlichen Slack-Bereich (~30-fache Schreibbeschleunigung).
- Inaktivitäts-Tracking: `self.touch()` reagiert nur noch auf echte I/O-Interaktionen (`open`, read, write) und ignoriert passive Explorer-Hintergrundabfragen (`metadata`, `read_dir`).
- LZ4-Kompression: Schwellenwert von mindestens 64 Bytes Ersparnis eingeführt (`compressed.len() + 64 <= plaintext.len()`).
## [0.2.0] - 2026-09-08 ## [0.2.0] - 2026-09-08
### Added ### Added
Generated
+1 -1
View File
@@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.3.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.3.1"
edition = "2021" edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"] authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -18,7 +18,7 @@ argon2 = { version = "0.5", features = ["password-hash"] }
aes-gcm = { version = "0.10", features = ["zeroize"] } aes-gcm = { version = "0.10", features = ["zeroize"] }
rand = "0.8" rand = "0.8"
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] } zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled", "backup"] }
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
dav-server = { version = "0.11", default-features = false } dav-server = { version = "0.11", default-features = false }
hyper = { version = "1.4", features = ["server", "http1"] } hyper = { version = "1.4", features = ["server", "http1"] }
+32 -3
View File
@@ -15,7 +15,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
- **Plausible Deniability (Hidden Safe)**: Dual-Slot-Header. Ein unbenutzter Slot enthält uniformes CSPRNG-Rauschen, das sich nicht von einem echten Hidden Vault unterscheiden lässt. Volle Dateinamen-Verschlüsselung im Hidden Vault. - **Plausible Deniability (Hidden Safe)**: Dual-Slot-Header. Ein unbenutzter Slot enthält uniformes CSPRNG-Rauschen, das sich nicht von einem echten Hidden Vault unterscheiden lässt. Volle Dateinamen-Verschlüsselung im Hidden Vault.
- **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`). - **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`).
- **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`). - **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`).
- **Statisches Single-Binary**: `sanctum.exe` (~4.6 MB) ohne externe DLL-Abhängigkeiten. - **Statisches Single-Binary**: `sanctum.exe` (~5.3 MB) ohne externe DLL-Abhängigkeiten.
--- ---
@@ -94,9 +94,15 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
# Mit Inaktivitäts-Timeout (in Sekunden): # Mit Inaktivitäts-Timeout (in Sekunden):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300 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). - **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. - **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. - **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
@@ -168,6 +174,29 @@ Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`). 1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`).
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts. 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. 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. **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.
---
## 🔧 Windows-Kompatibilität & AV/EDR-Tuning
### Windows Defender SmartScreen ("Unbekannter Herausgeber")
Wird die Binärdatei über einen Webbrowser heruntergeladen, versieht Windows sie mit dem *Mark-of-the-Web* (MotW). Da Open-Source-Projekte über kein kostenpflichtiges EV-Code-Signing-Zertifikat verfügen, zeigt Windows beim ersten Start den blauen SmartScreen-Dialog:
- **Einmalige Freigabe im Dialog:** Klick auf **"Weitere Informationen"** $\to$ **"Trotzdem ausführen"**.
- **Schnellfreigabe via PowerShell:**
```powershell
Unblock-File .\sanctum.exe
```
### Windows Defender "Überwachter Ordnerzugriff" (Controlled Folder Access - CFA)
Ist der erweiterte Ransomware-Schutz von Windows aktiv, kann Defender den Schreibzugriff auf Containerdateien in Standard-Benutzerordnern (`Dokumente`, `Desktop`) mit Fehler 5 (*Zugriff verweigert*) blockieren:
- **Abhilfe:** Erlauben Sie `sanctum.exe` in den Windows-Sicherheitseinstellungen (*Viren- & Bedrohungsschutz* $\to$ *Ransomware-Schutz verwalten* $\to$ *App durch überwachten Ordnerzugriff zulassen*), oder speichern Sie Container in einem separaten Verzeichnis (z. B. `C:\Sanctum\`).
### I/O-Performance-Tuning
Windows Defender scannt standardmäßig jeden modifizierten 1-MB-Chunk in Echtzeit. Durch das Hinzufügen der Dateiendung `*.sanctum` zu den Defender-Ausschlüssen lässt sich der I/O-Durchsatz bei großen Kopiervorgängen um 3050% steigern.
--- ---
@@ -180,7 +209,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
``` ```
Erzeugt: Erzeugt:
- `dist/sanctum-v0.2.0-windows-x86_64.zip` - `dist/sanctum-v0.3.1-windows-x86_64.zip`
- `dist/SHA256SUMS.txt` - `dist/SHA256SUMS.txt`
--- ---
+17 -8
View File
@@ -106,23 +106,32 @@ if (-not $Release) {
$UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets" $UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets"
$AuthHeader = "Authorization: token $Token"
# ZIP Asset hochladen # ZIP Asset hochladen
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan
$ZipResult = & curl.exe -s -X POST "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip" ` $ZipPathNorm = $ZipFile.Replace('\', '/')
-H "Authorization: token $Token" ` $ZipUrl = "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip"
-F "attachment=@$ZipFile" $ZipResult = & curl.exe -s -X POST $ZipUrl -H $AuthHeader -F "attachment=@$ZipPathNorm"
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green
# SHA256SUMS.txt hochladen # SHA256SUMS.txt hochladen
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
$SumResult = & curl.exe -s -X POST "$UploadUrl?name=SHA256SUMS.txt" ` $SumPathNorm = $ChecksumFile.Replace('\', '/')
-H "Authorization: token $Token" ` $SumUrl = "$UploadUrl?name=SHA256SUMS.txt"
-F "attachment=@$ChecksumFile" $SumResult = & curl.exe -s -X POST $SumUrl -H $AuthHeader -F "attachment=@$SumPathNorm"
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
# Standalone EXE hochladen
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
if (Test-Path $ExeFile) {
Write-Host "Lade sanctum.exe hoch..." -ForegroundColor Cyan
$ExePathNorm = $ExeFile.Replace('\', '/')
$ExeUrl = "$UploadUrl?name=sanctum.exe"
$ExeResult = & curl.exe -s -X POST $ExeUrl -H $AuthHeader -F "attachment=@$ExePathNorm"
Write-Host "[OK] sanctum.exe hochgeladen." -ForegroundColor Green
}
Write-Host "`n============================================================" -ForegroundColor Green Write-Host "`n============================================================" -ForegroundColor Green
Write-Host " Release $TagName erfolgreich auf Gitea veroeffentlicht!" -ForegroundColor Green Write-Host " Release $TagName erfolgreich auf Gitea veroeffentlicht!" -ForegroundColor Green
+1046
View File
File diff suppressed because it is too large Load Diff
+433 -54
View File
@@ -78,11 +78,10 @@ pub fn generate_salt() -> [u8; 16] {
salt salt
} }
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM. /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. pub fn wrap_key_payload(
pub fn wrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
dek: &[u8; 32], payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek) let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -91,10 +90,10 @@ pub fn wrap_dek(
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec(); let mut buffer = payload.to_vec();
let tag = cipher let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) .encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?; .map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16]; let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice()); tag_bytes.copy_from_slice(tag.as_slice());
@@ -102,6 +101,36 @@ pub fn wrap_dek(
Ok((buffer, nonce_bytes, tag_bytes)) Ok((buffer, nonce_bytes, tag_bytes))
} }
/// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_key_payload(
kek: &[u8; 32],
wrapped_payload: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Zeroizing<Vec<u8>>> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität. /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek( pub fn unwrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
@@ -109,31 +138,47 @@ pub fn unwrap_dek(
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> { ) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len()); if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
} }
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_dek.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
let mut dek = Zeroizing::new([0u8; 32]); let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer); dek.copy_from_slice(&payload[0..32]);
Ok(dek) Ok(dek)
} }
/// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot0_payload(
kek: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(40);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot1_payload(
kek: &[u8; 32],
dek_1: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(72);
payload.extend_from_slice(dek_1);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie /// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter KDF/DEK-Slot. Dadurch sind Standard-Container von Containern mit Hidden Vault /// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). /// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) { pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
let mut wrapped_dek = vec![0u8; 32]; let mut wrapped_dek = vec![0u8; 72];
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
let mut tag = [0u8; 16]; let mut tag = [0u8; 16];
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
@@ -144,26 +189,42 @@ pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
(wrapped_dek, nonce, tag, salt) (wrapped_dek, nonce, tag, salt)
} }
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM. /// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault,
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind. /// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern:
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { /// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian).
#[inline]
pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
let mut aad = [0u8; 16];
aad[..8].copy_from_slice(b"SANCNAME");
aad[8..].copy_from_slice(&parent_id.to_le_bytes());
aad
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init"); let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
let mut buffer = name.as_bytes().to_vec(); let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = cipher let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer) .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
.expect("Name encryption"); .expect("Name encryption");
format!( let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
"$h${}${}${}", combined.extend_from_slice(&nonce_bytes);
hex::encode(nonce_bytes), combined.extend_from_slice(tag.as_slice());
hex::encode(tag.as_slice()), combined.extend_from_slice(&buffer);
hex::encode(&buffer) hex::encode(combined)
)
} }
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> { /// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") { if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect(); let parts: Vec<&str> = rest.split('$').collect();
if parts.len() == 3 { if parts.len() == 3 {
@@ -174,11 +235,13 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
) { ) {
if nonce_bytes.len() == 12 && tag_bytes.len() == 16 { if nonce_bytes.len() == 12 && tag_bytes.len() == 16 {
let cipher = Aes256Gcm::new_from_slice(dek).ok()?; let cipher = Aes256Gcm::new_from_slice(dek).ok()?;
let mut buffer = ct_bytes; // 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct_bytes.clone();
if cipher if cipher
.decrypt_in_place_detached( .decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes), Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME", &aad,
&mut buffer, &mut buffer,
Tag::from_slice(&tag_bytes), Tag::from_slice(&tag_bytes),
) )
@@ -186,6 +249,63 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
{ {
return String::from_utf8(buffer).ok(); return String::from_utf8(buffer).ok();
} }
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
return None;
}
// Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext)
if stored.len() >= 56 {
if let Ok(bytes) = hex::decode(stored) {
if bytes.len() >= 28 {
let nonce = &bytes[0..12];
let tag = &bytes[12..28];
let ct = &bytes[28..];
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
// 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
&aad,
&mut buffer,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Alte statische AAD für bestehende Container
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
} }
} }
} }
@@ -200,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 {
@@ -259,8 +484,8 @@ pub fn encrypt_chunk(
vec![COMPRESSION_NONE] vec![COMPRESSION_NONE]
} else { } else {
let compressed = lz4_flex::compress_prepend_size(plaintext); let compressed = lz4_flex::compress_prepend_size(plaintext);
// Nur komprimieren, wenn tatsächlich Bytes gespart werden (+1 Byte für das Flag) // Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag)
if compressed.len() + 1 < plaintext.len() { if compressed.len() + 64 <= plaintext.len() {
let mut buf = Vec::with_capacity(compressed.len() + 1); let mut buf = Vec::with_capacity(compressed.len() + 1);
buf.push(COMPRESSION_LZ4); buf.push(COMPRESSION_LZ4);
buf.extend_from_slice(&compressed); buf.extend_from_slice(&compressed);
@@ -316,7 +541,19 @@ pub fn decrypt_chunk(
match buffer[0] { match buffer[0] {
COMPRESSION_NONE => Ok(buffer[1..].to_vec()), COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
COMPRESSION_LZ4 => { COMPRESSION_LZ4 => {
let decompressed = lz4_flex::decompress_size_prepended(&buffer[1..]) let payload = &buffer[1..];
if payload.len() < 4 {
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
}
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
if uncompressed_size > CHUNK_SIZE {
bail!(
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
uncompressed_size,
CHUNK_SIZE
);
}
let decompressed = lz4_flex::decompress_size_prepended(payload)
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?; .map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
Ok(decompressed) Ok(decompressed)
} }
@@ -420,6 +657,22 @@ mod tests {
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
} }
#[test]
fn test_lz4_chunk_compression_threshold() {
let dek = generate_dek();
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
let mut random_bytes = vec![0u8; 1000];
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
assert_eq!(decrypted, random_bytes);
}
#[test] #[test]
fn test_v1_backward_compatibility() { fn test_v1_backward_compatibility() {
let dek = generate_dek(); let dek = generate_dek();
@@ -477,27 +730,153 @@ 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();
let filename = "ultra_geheimes_dokument.pdf"; let filename = "ultra_geheimes_dokument.pdf";
let encrypted = encrypt_node_name(&dek, filename); let parent_id = 2i64;
assert!(encrypted.starts_with("$h$")); let encrypted = encrypt_node_name(&dek, parent_id, filename);
// Kein verräterisches Präfix mehr! Reines Hex.
assert!(!encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename)); assert!(!encrypted.contains(filename));
assert!(encrypted.len() >= 56);
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name"); let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename); assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap();
let mut static_buf = filename.as_bytes().to_vec();
let static_nonce = [42u8; 12];
let static_tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
.unwrap();
let legacy_static_format = format!(
"$h${}${}${}",
hex::encode(static_nonce),
hex::encode(static_tag),
hex::encode(&static_buf)
);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
assert_eq!(decrypted_static, filename);
// Mit anderem DEK schlägt Entschlüsselung fehl // Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek(); let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none());
// Dummy-Slot hat korrekte Längen // Dummy-Slot hat korrekte Längen (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
assert_eq!(dummy_dek.len(), 32); assert_eq!(dummy_dek.len(), 72);
assert_eq!(dummy_nonce.len(), 12); assert_eq!(dummy_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.len(), 16); assert_eq!(dummy_salt.len(), 16);
} }
#[test]
fn test_node_name_aad_parent_binding() {
let dek = generate_dek();
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
} }
#[test]
fn test_model_a_slot_payloads() {
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let dek_0 = generate_dek();
let dek_1 = generate_dek();
let carrier_node_id = 42i64;
// Slot 0 Payload: 40 Bytes
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_0.len(), 40);
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
assert_eq!(unwrapped_0.len(), 40);
assert_eq!(&unwrapped_0[0..32], &*dek_0);
let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap());
assert_eq!(recovered_cid_0, carrier_node_id);
// Slot 1 Payload: 72 Bytes
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_1.len(), 72);
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
assert_eq!(unwrapped_1.len(), 72);
assert_eq!(&unwrapped_1[0..32], &*dek_1);
assert_eq!(&unwrapped_1[32..64], &*dek_0);
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
assert_eq!(recovered_cid_1, carrier_node_id);
}
#[test]
fn test_lz4_decompression_bomb_protection() {
use aes_gcm::KeyInit;
let dek = generate_dek();
let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap();
let node_id = 999;
let chunk_index = 0;
let aad = build_chunk_aad(node_id, chunk_index);
// Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE)
let mut malicious_plaintext = Vec::new();
malicious_plaintext.push(COMPRESSION_LZ4);
let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB
malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes());
malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload
let mut nonce_bytes = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut ct = malicious_plaintext.clone();
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
let err_msg = res.err().unwrap().to_string();
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod carrier;
pub mod crypto; pub mod crypto;
pub mod mount; pub mod mount;
pub mod recovery; pub mod recovery;
+356 -119
View File
@@ -3,14 +3,15 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use zeroize::Zeroizing;
use sanctum::crypto::{ use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek, dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
wrap_dek, KdfParams, FORMAT_VERSION, wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{ use sanctum::recovery::{
export_header_backup, restore_header_backup, restore_header_from_recovery_key, export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
}; };
use sanctum::storage::Database; use sanctum::storage::Database;
use sanctum::ui; use sanctum::ui;
@@ -37,6 +38,17 @@ enum Commands {
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability) /// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
with_hidden: bool, with_hidden: bool,
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
/// in forensischen Entropie-Scannern zu verhindern.
#[arg(long, default_value = "system_backup.dat")]
carrier_name: String,
/// Größe der Trägerdatei (z. B. 100MB, 500MB, 1GB, 2GB; Standard: 1GB)
#[arg(long, default_value = "1GB")]
carrier_size: String,
}, },
/// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten /// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
@@ -64,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
@@ -87,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
@@ -102,11 +120,34 @@ 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>,
}, },
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
Backup {
/// Pfad zur Quelldatei (.sanctum Containerdatei)
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Ziel-Backup-Datei (.sanctum.bak)
#[arg(short, long)]
output: PathBuf,
},
/// Stellt einen Container aus einem Backup wieder her (inklusive B-Tree Integritätsprüfung)
Restore {
/// Pfad zur Backup-Datei (.sanctum.bak)
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Ziel-Containerdatei (.sanctum)
#[arg(short, long)]
output: PathBuf,
},
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr) /// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
BackupHeader { BackupHeader {
/// Pfad zur .sanctum Containerdatei /// Pfad zur .sanctum Containerdatei
@@ -128,9 +169,14 @@ 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)
#[arg(long, default_value_t = 0)]
slot: u32,
}, },
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an /// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
@@ -172,7 +218,43 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase()) Ok(ch.to_ascii_uppercase())
} }
fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { pub fn parse_size_string(s: &str) -> Result<u64> {
let trimmed = s.trim().to_uppercase();
if let Some(num_str) = trimmed.strip_suffix("GB") {
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
Ok(n * 1024 * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
Ok(n * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
Ok(n * 1024)
} else if let Ok(n) = trimmed.parse::<u64>() {
Ok(n)
} else {
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
}
}
/// 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,
carrier_name: &str,
carrier_size_str: &str,
) -> Result<()> {
if container_path.exists() { if container_path.exists() {
bail!( bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
@@ -185,37 +267,58 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display()); println!(" Zieldatei: {}", container_path.display());
if with_hidden { if with_hidden {
println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault")); println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
let lower = carrier_name.to_lowercase();
if lower.ends_with(".iso") || lower.ends_with(".vhd") || lower.ends_with(".img") || lower.ends_with(".zip") {
println!(
" [{}] Hinweis zur Plausible Deniability: Dateitypen wie '.iso' oder '.zip' besitzen ein starres Format. Da Carrier-Blöcke verschlüsselt sind (Entropie ~8.0), empfehlen sich unformatierte Typen wie '.dat', '.bin' oder '.enc' für maximale forensische Unauffälligkeit.",
ui::yellow("Tipp")
);
}
} }
println!(); println!();
if with_hidden { if with_hidden {
let carrier_size_bytes = parse_size_string(carrier_size_str)?;
if carrier_size_bytes < 2 * 1024 * 1024 {
bail!("Trägerdateigröße muss mindestens 2 MB betragen (Block 0 Manifest + mindestens 1 Datenblock)");
}
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───"); println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") let password_0 = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password_0.trim().is_empty() { if password_0.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein."); bail!("Das Master-Passwort darf nicht leer sein.");
} }
let confirm_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ") let confirm_0 = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
if password_0 != confirm_0 { .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!"); bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!");
} }
println!(); println!();
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───"); println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG")); 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: ") let password_1 = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password_1.trim().is_empty() { if password_1.trim().is_empty() {
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein."); bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
} }
if password_1 == password_0 { if *password_1 == *password_0 {
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!"); 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: ") let confirm_1 = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
if password_1 != confirm_1 { .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!"); bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!");
} }
@@ -233,21 +336,32 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
let dek_0 = generate_dek(); let dek_0 = generate_dek();
let dek_1 = generate_dek(); let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM..."); ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?; let carrier_node_id = 3i64;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?; let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus..."); ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?; .context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_hidden( db.init_schema_with_carrier(
&salt_0, &salt_0,
&kdf_params_0, &kdf_params_0,
&wrapped_dek_0, &wrapped_dek_0,
&nonce_0, &nonce_0,
&tag_0, &tag_0,
Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_1)), Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params_1,
&wrapped_dek_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)?; )?;
db.checkpoint()?; db.checkpoint()?;
@@ -257,14 +371,16 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │"); println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! ");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(); println!();
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • Alibi-Datei: {} ({})", carrier_name, carrier_size_str);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)"); println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!("Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung"); println!("Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
println!("Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie"); println!("Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
println!(); println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:"); println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
@@ -281,17 +397,21 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!(" └─────────────────────────────────────────────────────────┘"); println!(" └─────────────────────────────────────────────────────────┘");
ui::print_recovery_phrase_card(&phrase_1); ui::print_recovery_phrase_card(&phrase_1);
} else { } else {
let password = rpassword::prompt_password("Master-Passwort eingeben: ") let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password.trim().is_empty() { if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein."); bail!("Das Master-Passwort darf nicht leer sein.");
} }
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ") let confirm_password = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
);
if password != confirm_password { if *password != *confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!"); bail!("Die eingegebenen Passwörter stimmen nicht überein!");
} }
@@ -307,13 +427,13 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM..."); ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) = let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?; wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus..."); ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?; .context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None)
.context("Fehler bei der Schema-Initialisierung")?; .context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint() db.checkpoint()
@@ -406,14 +526,21 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let (dek, target_slot_id) = if let Some(phrase) = recovery_key { let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); let actual_phrase = if phrase.trim().is_empty() {
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; prompt_recovery_key_interactive()?
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0)
} else { } else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") Zeroizing::new(phrase.to_string())
.context("Fehler beim Einlesen des aktuellen Passworts")?; };
ui::step(1, 3, "🔑", "Lese DEK aus 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 {
let old_password = Zeroizing::new(
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?,
);
if old_password.trim().is_empty() { if old_password.trim().is_empty() {
bail!("Das aktuelle Master-Passwort darf nicht leer sein."); bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
@@ -425,37 +552,31 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort..."); ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
let mut found = None; let keys = meta
for slot in &meta.slots { .authenticate(&old_password)
if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) { .ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
if let Ok(d) = unwrap_dek( let d = keys.dek().clone();
&old_kek, let slot_id = keys.slot_id();
&slot.wrapped_dek, (d, slot_id, keys.carrier_dek(), keys.carrier_node_id())
&slot.header_nonce,
&slot.header_tag,
) {
found = Some((d, slot.slot_id));
break;
}
}
}
found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
}; };
println!(); println!();
let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ") let new_password = Zeroizing::new(
.context("Fehler beim Einlesen des neuen Passworts")?; rpassword::prompt_password("Neues Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des neuen Passworts")?,
);
if new_password.trim().is_empty() { if new_password.trim().is_empty() {
bail!("Das neue Master-Passwort darf nicht leer sein."); bail!("Das neue Master-Passwort darf nicht leer sein.");
} }
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ") let confirm_password = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
);
if new_password != confirm_password { if *new_password != *confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!"); bail!("Die eingegebenen Passwörter stimmen nicht überein!");
} }
@@ -466,8 +587,16 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let new_kek = derive_kek(&new_password, &new_salt, &new_params) let new_kek = derive_kek(&new_password, &new_salt, &new_params)
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?; .context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
let (new_wrapped_dek, new_nonce, new_tag) = let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?; let c_dek = carrier_dek.unwrap_or_else(generate_dek);
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
} else {
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot0_payload(&new_kek, &dek, c_nid)
.context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")?
};
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus..."); ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
db.update_slot_keys( db.update_slot_keys(
@@ -527,10 +656,79 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re
Ok(()) Ok(())
} }
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
if output_path.exists() {
bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Online-Backup erstellen (Hot-Backup) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!(" Backup-Ziel: {}", output_path.display());
println!();
ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream...");
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API...");
db.online_backup(output_path)
.context("Fehler beim Erstellen des Online-Backups")?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Online-Backup erfolgreich abgeschlossen! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Backup-Datei: {}", output_path.display());
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
println!();
Ok(())
}
fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
if !backup_path.exists() {
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
if output_path.exists() {
bail!("Zieldatei '{}' existiert bereits. Wiederherstellung abgebrochen, um Überschreiben zu verhindern.", output_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container aus Backup wiederherstellen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Backup-Quelle: {}", backup_path.display());
println!(" Container-Ziel: {}", output_path.display());
println!();
ui::step(1, 2, "📦", "Kopiere Backup-Seiten in Ziel-Container...");
Database::restore_from_backup(backup_path, output_path)
.context("Fehler bei der Container-Wiederherstellung aus dem Backup")?;
ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!");
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Container erfolgreich aus Backup wiederhergestellt! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Neuer Container: {}", output_path.display());
println!(" • Integrität: Vollständig intakt & bereit zum Mounten");
println!();
Ok(())
}
fn handle_restore_header( fn handle_restore_header(
container_path: &Path, container_path: &Path,
header_file: Option<&Path>, header_file: Option<&Path>,
recovery_key: Option<&str>, recovery_key: Option<&str>,
slot: u32,
) -> Result<()> { ) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Header wiederherstellen │"); println!("│ Sanctum — Container-Header wiederherstellen │");
@@ -545,29 +743,61 @@ 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 {
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel..."); let key_str = if let Some(key) = recovery_key {
let new_password = rpassword::prompt_password("Neues Master-Passwort festlegen: ") if key.trim().is_empty() {
.context("Fehler beim Einlesen des Passworts")?; 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: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if new_password.trim().is_empty() { if new_password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein."); bail!("Das Master-Passwort darf nicht leer sein.");
} }
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ") let confirm_password = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
);
if new_password != confirm_password { if *new_password != *confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!"); bail!("Die eingegebenen Passwörter stimmen nicht überein!");
} }
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_header_from_recovery_key(container_path, key, &new_password)?; restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?;
ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!"); 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!();
@@ -585,29 +815,21 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
println!(" Container: {}", container_path.display()); println!(" Container: {}", container_path.display());
println!(); println!();
let password = rpassword::prompt_password("Master-Passwort eingeben: ") let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let mut found = None; let keys = meta
for slot in &meta.slots { .authenticate(&password)
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { let dek = keys.dek().clone();
found = Some((dek, slot.slot_id));
break;
}
}
}
let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?; let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
}
ui::print_recovery_phrase_card(&phrase); ui::print_recovery_phrase_card(&phrase);
Ok(()) Ok(())
@@ -624,27 +846,19 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
println!(" Container: {}", container_path.display()); println!(" Container: {}", container_path.display());
println!(); println!();
let password = rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ") let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ")
.context("Fehler beim Einlesen des Passworts")?,
);
let dek = if !password.trim().is_empty() { let dek = if !password.trim().is_empty() {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?; let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
let meta = db.read_meta().context("Konnte Header nicht lesen")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?;
let mut found = None; match meta.authenticate(&password) {
for slot in &meta.slots { Some(keys) => {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some(d);
break;
}
}
}
match found {
Some(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("")); println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
Some(d) Some(keys.dek().clone())
} }
None => { None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
@@ -670,12 +884,23 @@ async fn run() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Init { path, with_hidden } => { Commands::Init {
handle_init(&path, with_hidden)?; path,
with_hidden,
carrier_name,
carrier_size,
} => {
handle_init(&path, with_hidden, &carrier_name, &carrier_size)?;
} }
Commands::Compact { path, pages } => { Commands::Compact { path, pages } => {
handle_compact(&path, pages)?; handle_compact(&path, pages)?;
} }
Commands::Backup { path, output } => {
handle_backup(&path, &output)?;
}
Commands::Restore { path, output } => {
handle_restore(&path, &output)?;
}
Commands::Mount { Commands::Mount {
path, path,
drive, drive,
@@ -686,33 +911,43 @@ 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()?;
if !stealth {
println!( println!(
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:", " {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
ui::cyan(""), ui::cyan(""),
auto_drive auto_drive
); );
}
auto_drive auto_drive
} }
}; };
let auth = if let Some(key) = recovery_key { let auth = if let Some(key) = recovery_key {
ContainerAuth::RecoveryKey(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: ",
path.display() path.display()
); );
let password = rpassword::prompt_password(prompt_text) let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password(prompt_text)
.context("Fehler beim Einlesen des Passworts")?,
);
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(
@@ -725,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?;
} }
@@ -747,8 +983,9 @@ async fn run() -> Result<()> {
path, path,
header_file, header_file,
recovery_key, recovery_key,
slot,
} => { } => {
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref())?; handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref(), slot)?;
} }
Commands::RecoveryKey { path } => { Commands::RecoveryKey { path } => {
handle_recovery_key(&path)?; handle_recovery_key(&path)?;
+271 -82
View File
@@ -13,8 +13,9 @@ use hyper_util::rt::TokioIo;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, warn}; use tracing::{debug, warn};
use zeroize::Zeroizing;
use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek}; use crate::crypto::mnemonic_to_dek;
use crate::storage::Database; use crate::storage::Database;
use crate::ui; use crate::ui;
use crate::vfs::SanctumFs; use crate::vfs::SanctumFs;
@@ -22,8 +23,8 @@ use crate::vfs::SanctumFs;
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel. /// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ContainerAuth { pub enum ContainerAuth {
Password(String), Password(Zeroizing<String>),
RecoveryKey(String), RecoveryKey(Zeroizing<String>),
} }
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
@@ -54,23 +55,54 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
Ok(()) Ok(())
} }
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/ /persistent:no` ein. /// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein.
fn run_net_use_mount(drive_str: &str, port: u16) -> Result<()> { fn run_net_use_mount(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
let url = format!("http://127.0.0.1:{}/", port); let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
let output = Command::new("net") let mut output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"]) .args(["use", drive_str, &url, "/persistent:no"])
.output() .output()
.context("Fehler beim Ausführen des Befehls 'net use'")?; .context("Fehler beim Ausführen des Befehls 'net use'")?;
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let is_already_in_use = stderr.contains("85")
|| stderr.contains("bereits verwendet")
|| stderr.contains("already in use")
|| stdout.contains("85")
|| stdout.contains("bereits verwendet")
|| stdout.contains("already in use");
if is_already_in_use {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
let _ = Command::new("net")
.args(["use", drive_str, "/delete", "/y"])
.output();
// Zweiter Versuch nach automatischer Bereinigung
output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim erneuten Ausführen des Befehls 'net use'")?;
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!( bail!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}", "Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
drive_str, drive_str,
stdout, stdout,
stderr stderr,
webclient_hint
); );
} }
@@ -88,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);
@@ -98,8 +131,10 @@ pub async fn mount_container(
); );
} }
if !stealth {
println!(); println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); 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")?;
@@ -107,29 +142,21 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
let (dek, 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..."); if !stealth {
let mut unwrapped = None; ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
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,
) {
unwrapped = Some((dek, slot.version, slot.slot_id));
break;
} }
} match meta.authenticate(password) {
} Some(keys) => {
if !stealth {
match unwrapped {
Some((dek, ver, slot_id)) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id) }
let vault_id = keys.slot_id();
let ver = keys.version();
let c_dek = keys.carrier_dek();
let c_nid = keys.carrier_node_id();
(keys.0, c_dek, c_nid, ver, vault_id)
} }
None => { None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt"); bail!("Ungültiges Master-Passwort oder Container beschädigt");
@@ -137,28 +164,44 @@ pub async fn mount_container(
} }
} }
ContainerAuth::RecoveryKey(ref phrase) => { ContainerAuth::RecoveryKey(ref phrase) => {
if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase) let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?; .context("Ungültiger 24-Wort Notfallschlüssel")?;
if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
let vault_id = if db.has_hidden_vault().unwrap_or(false) { // Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
let is_hidden = { let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty() !children.is_empty()
}; };
if is_hidden { 1 } else { 0 } let vault_id = if is_hidden { 1 } else { 0 };
} else { (dek, None, None, meta.version, vault_id)
0
};
(dek, meta.version, vault_id)
} }
}; };
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing) // 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
let token_path_prefix = format!("/{}", session_token);
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
db.clone(),
dek,
carrier_dek,
carrier_node_id,
version,
anti_leak,
vault_id,
);
let last_activity = fs.last_activity(); let last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
.locksystem(FakeLs::new()) .locksystem(FakeLs::new())
.build_handler(); .build_handler();
@@ -180,6 +223,7 @@ 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();
if !stealth {
ui::step( ui::step(
4, 4,
4, 4,
@@ -189,56 +233,26 @@ pub async fn mount_container(
bound_port, drive_str bound_port, drive_str
), ),
); );
}
let (shutdown_tx, mut shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Hyper HTTP Server Loop im Hintergrund starten // Hyper HTTP Server Loop im Hintergrund starten
let server_dav = dav_server.clone(); let server_handle = tokio::spawn(serve_webdav_loop(
let server_handle = tokio::spawn(async move { listener,
loop { dav_server,
tokio::select! { token_path_prefix.clone(),
res = listener.accept() => { shutdown_rx,
let (stream, _) = match res { ));
Ok(val) => val,
Err(e) => {
warn!("Verbindungsfehler im TCP-Listener: {e}");
continue;
}
};
let io = TokioIo::new(stream);
let handler = server_dav.clone();
tokio::spawn(async move {
let service = service_fn(move |req| {
let h = handler.clone();
async move {
Ok::<_, Infallible>(h.handle(req).await)
}
});
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
// Client-Disconnects im Explorer sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
}
_ = shutdown_rx.changed() => {
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
break;
}
}
}
});
// Netzlaufwerk einbinden // Netzlaufwerk einbinden
if let Err(e) = run_net_use_mount(&drive_str, bound_port) { if let Err(e) = run_net_use_mount(&drive_str, bound_port, &session_token) {
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
let _ = server_handle.await; let _ = server_handle.await;
return Err(e); return Err(e);
} }
// Optional automatisch im Windows Explorer öffnen // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
if open_explorer { if open_explorer {
let _ = crate::windows::open_in_explorer(drive_letter); let _ = crate::windows::open_in_explorer(drive_letter);
} }
@@ -315,6 +329,9 @@ pub async fn mount_container(
None None
}; };
if stealth {
println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
} else {
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Container erfolgreich gemountet │"); println!("│ ✔ Sanctum Container erfolgreich gemountet │");
@@ -322,10 +339,7 @@ pub async fn mount_container(
println!(); println!();
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
if vault_id == 1 {
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
}
if let Some(secs) = idle_timeout { if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs); println!(" • Auto-Lock: Inaktivität nach {}s", secs);
} }
@@ -341,27 +355,59 @@ pub async fn mount_container(
println!(); println!();
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp")); println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!(); println!();
}
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L) // 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_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
tokio::select! { tokio::select! {
res = tokio::signal::ctrl_c() => { res = tokio::signal::ctrl_c() => {
let _ = res; let _ = res;
if !stealth {
println!(); println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
} }
}
_ = console_close_rx.recv() => {
if !stealth {
println!();
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
}
}
_ = tray_shutdown_rx.recv() => { _ = tray_shutdown_rx.recv() => {
if !stealth {
println!(); println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
} }
}
_ = session_lock_rx.recv() => { _ = session_lock_rx.recv() => {
if !stealth {
println!(); println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
} }
}
_ = idle_shutdown_rx.recv() => { _ = idle_shutdown_rx.recv() => {
if !stealth {
println!(); println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
} }
} }
}
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 {
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout()); let _ = std::io::Write::flush(&mut std::io::stdout());
@@ -400,6 +446,149 @@ pub async fn mount_container(
println!(); println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("")); println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!(); println!();
}
Ok(()) Ok(())
} }
/// Überprüft, ob ein Host-Header auf Loopback (127.0.0.1, localhost, [::1]) zeigt (Anti-DNS-Rebinding).
pub fn is_loopback_host(host_str: &str) -> bool {
let host_lower = host_str.to_ascii_lowercase();
host_lower.starts_with("127.0.0.1")
|| host_lower.starts_with("localhost")
|| host_lower.starts_with("[::1]")
}
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
token_path_prefix: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
loop {
tokio::select! {
res = listener.accept() => {
let (stream, _) = match res {
Ok(val) => val,
Err(e) => {
warn!("Verbindungsfehler im TCP-Listener: {e}");
continue;
}
};
// RT-01: Schutz gegen Connection-Starvation / Socket-Flooding (CWE-400)
let permit = match conn_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
warn!(
"WebDAV-Verbindungslimit ({} aktive Verbindungen) erreicht: Wehre potenziellen Connection-Starvation-Angriff ab.",
MAX_CONCURRENT_DAV_CONNECTIONS
);
drop(stream);
continue;
}
};
let io = TokioIo::new(stream);
let handler = dav_server.clone();
let expected_prefix = token_path_prefix.clone();
tokio::spawn(async move {
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
let service = service_fn(move |req| {
let h = handler.clone();
let prefix = expected_prefix.clone();
async move {
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
let host_valid = match req.headers().get(hyper::header::HOST) {
Some(host_val) => match host_val.to_str() {
Ok(host_str) => is_loopback_host(host_str),
Err(_) => false,
},
None => false,
};
if !host_valid {
warn!(
"Abgewiesener Zugriff: Fehlender, ungültiger oder externer Host-Header ({:?})",
req.headers().get(hyper::header::HOST)
);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
let path = req.uri().path();
if !path.starts_with(&prefix) {
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
Ok::<_, Infallible>(h.handle(req).await)
}
});
// RT-01: Header-Read-Timeout & TokioTimer zur aktiven Abwehr von Slowloris-Hanging-Sockets
let mut builder = http1::Builder::new();
builder.timer(hyper_util::rt::TokioTimer::new());
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
if let Err(err) = builder.serve_connection(io, service).await {
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
}
_ = shutdown_rx.changed() => {
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_loopback_host_validation() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8443"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:8443"));
// Abweisung externer Hosts oder DNS-Rebinding-Attacken
assert!(!is_loopback_host("evil.com"));
assert!(!is_loopback_host("attacker.local"));
assert!(!is_loopback_host("192.168.1.50"));
assert!(!is_loopback_host("10.0.0.1"));
assert!(!is_loopback_host(""));
}
#[test]
fn test_format_drive() {
assert_eq!(format_drive('s'), "S:");
assert_eq!(format_drive('Z'), "Z:");
}
}
+156 -29
View File
@@ -6,13 +6,26 @@ use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::crypto::{ use crate::crypto::{
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use crate::storage::{ContainerMeta, Database, SlotMeta}; use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1; pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für einen gesicherten Header-Slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotBackup {
pub slot_id: u32,
pub version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
}
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format. /// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup { pub struct HeaderBackup {
@@ -24,6 +37,8 @@ pub struct HeaderBackup {
pub wrapped_dek_hex: String, pub wrapped_dek_hex: String,
pub header_nonce_hex: String, pub header_nonce_hex: String,
pub header_tag_hex: String, pub header_tag_hex: String,
#[serde(default)]
pub slots: Vec<SlotBackup>,
pub created_at: u64, pub created_at: u64,
} }
@@ -34,6 +49,20 @@ impl HeaderBackup {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let slots: Vec<SlotBackup> = meta
.slots
.iter()
.map(|s| SlotBackup {
slot_id: s.slot_id,
version: s.version,
kdf_salt_hex: hex::encode(s.kdf_salt),
kdf_params: s.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
header_nonce_hex: hex::encode(s.header_nonce),
header_tag_hex: hex::encode(s.header_tag),
})
.collect();
Self { Self {
magic: HEADER_BACKUP_MAGIC.to_string(), magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION, backup_version: CURRENT_BACKUP_VERSION,
@@ -43,6 +72,7 @@ impl HeaderBackup {
wrapped_dek_hex: hex::encode(&meta.wrapped_dek), wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce), header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag), header_tag_hex: hex::encode(meta.header_tag),
slots,
created_at: now, created_at: now,
} }
} }
@@ -52,6 +82,49 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix"); bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
} }
let mut slots = Vec::new();
if !self.slots.is_empty() {
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
slots.push(SlotMeta {
slot_id: s.slot_id,
version: s.version,
kdf_salt,
kdf_params: s.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
});
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex) let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?; .context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 { if salt_bytes.len() != 16 {
@@ -88,15 +161,18 @@ impl HeaderBackup {
header_nonce, header_nonce,
header_tag, header_tag,
}; };
slots.push(slot0);
}
let slot0 = &slots[0];
Ok(ContainerMeta { Ok(ContainerMeta {
version: self.container_format_version, version: slot0.version,
kdf_salt, kdf_salt: slot0.kdf_salt,
kdf_params: self.kdf_params.clone(), kdf_params: slot0.kdf_params.clone(),
wrapped_dek, wrapped_dek: slot0.wrapped_dek.clone(),
header_nonce, header_nonce: slot0.header_nonce,
header_tag, header_tag: slot0.header_tag,
slots: vec![slot0], slots,
}) })
} }
} }
@@ -146,12 +222,13 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
Ok(()) Ok(())
} }
/// Rekonstruiert den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels /// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
/// und initialisiert ein neues Master-Passwort. /// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
pub fn restore_header_from_recovery_key( pub fn restore_slot_from_recovery_key(
container_path: &Path, container_path: &Path,
recovery_key: &str, recovery_key: &str,
new_password: &str, new_password: &str,
target_slot_id: u32,
) -> Result<()> { ) -> Result<()> {
if !container_path.exists() { if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display()); bail!("Containerdatei '{}' existiert nicht.", container_path.display());
@@ -173,34 +250,75 @@ pub fn restore_header_from_recovery_key(
let kek = derive_kek(new_password, &salt, &kdf_params) let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?; .context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
// 3. DEK mit neuem KEK wrappen let db = Database::open(container_path)
let (wrapped_dek, header_nonce, header_tag) = .context("Konnte Container-Datenbank nicht öffnen")?;
wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?;
let slot0 = SlotMeta { let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
let mut existing_slots = db.read_slots().unwrap_or_default();
let new_slot = if target_slot_id == 1 {
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
let dek_0_bytes = [0u8; 32];
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped,
header_nonce: nonce,
header_tag: tag,
}
} else {
// Slot 0 (Standard / Decoy Vault) mit 40 Bytes für Modell A
let (wrapped, nonce, tag) = wrap_slot0_payload(&kek, &dek, carrier_node_id)?;
SlotMeta {
slot_id: 0, slot_id: 0,
version: FORMAT_VERSION, version: FORMAT_VERSION,
kdf_salt: salt, kdf_salt: salt,
kdf_params: kdf_params.clone(), kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(), wrapped_dek: wrapped,
header_nonce, header_nonce: nonce,
header_tag, header_tag: tag,
}
}; };
// Slot ersetzen bzw. einfügen
existing_slots.retain(|s| s.slot_id != target_slot_id);
existing_slots.push(new_slot);
existing_slots.sort_by_key(|s| s.slot_id);
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
if !existing_slots.iter().any(|s| s.slot_id == 1) {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_slot = SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
kdf_salt: dummy_salt,
kdf_params: KdfParams::default(),
wrapped_dek: dummy_dek,
header_nonce: dummy_nonce,
header_tag: dummy_tag,
};
existing_slots.push(dummy_slot);
}
let slot0_meta = existing_slots
.iter()
.find(|s| s.slot_id == 0)
.ok_or_else(|| anyhow::anyhow!("Slot 0 fehlt im Header"))?;
let meta = ContainerMeta { let meta = ContainerMeta {
version: FORMAT_VERSION, version: FORMAT_VERSION,
kdf_salt: salt, kdf_salt: slot0_meta.kdf_salt,
kdf_params, kdf_params: slot0_meta.kdf_params.clone(),
wrapped_dek, wrapped_dek: slot0_meta.wrapped_dek.clone(),
header_nonce, header_nonce: slot0_meta.header_nonce,
header_tag, header_tag: slot0_meta.header_tag,
slots: vec![slot0], slots: existing_slots,
}; };
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta) db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?; .context("Fehler beim Schreiben des rekonstruierten Headers")?;
@@ -209,10 +327,19 @@ pub fn restore_header_from_recovery_key(
Ok(()) Ok(())
} }
/// Rekonstruiert den Decoy-Slot (Slot 0) mithilfe des 24-Wort BIP-39 Notfallschlüssels.
pub fn restore_header_from_recovery_key(
container_path: &Path,
recovery_key: &str,
new_password: &str,
) -> Result<()> {
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::crypto::{generate_dek, generate_salt, unwrap_dek}; use crate::crypto::{generate_dek, generate_salt, unwrap_dek, wrap_dek};
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
+561 -56
View File
@@ -8,9 +8,10 @@ use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{ use crate::crypto::{
decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION, decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
}; };
use zeroize::Zeroizing;
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -45,6 +46,36 @@ pub struct SlotMeta {
pub header_tag: [u8; 16], pub header_tag: [u8; 16],
} }
/// Ergebnis einer erfolgreichen Authentifizierung eines Container-Slots.
/// Die Tupel-Struktur (0: DEK, 1: Version, 2: Slot-ID, 3: Carrier-DEK, 4: Carrier-Node-ID)
/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`).
#[derive(Clone)]
pub struct UnlockedKeys(
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
pub u32, // 1: Formatversion
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
pub Option<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
);
impl UnlockedKeys {
pub fn dek(&self) -> &Zeroizing<[u8; 32]> {
&self.0
}
pub fn version(&self) -> u32 {
self.1
}
pub fn slot_id(&self) -> u32 {
self.2
}
pub fn carrier_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
self.3.clone()
}
pub fn carrier_node_id(&self) -> Option<i64> {
self.4
}
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContainerMeta { pub struct ContainerMeta {
@@ -57,6 +88,61 @@ pub struct ContainerMeta {
pub slots: Vec<SlotMeta>, pub slots: Vec<SlotMeta>,
} }
impl ContainerMeta {
/// Authentifiziert ein Master-Passwort über alle Header-Slots in strikt konstanter Zeit (Anti-Timing Side-Channel).
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
let mut matching = None;
for slot in &self.slots {
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
.ok()
.and_then(|kek| {
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
});
if let Some(payload) = res {
if matching.is_none() {
if slot.slot_id == 0 {
let mut dek = Zeroizing::new([0u8; 32]);
let carrier_node_id = if payload.len() >= 40 {
dek.copy_from_slice(&payload[0..32]);
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
if cid > 0 { Some(cid) } else { None }
} else if payload.len() >= 32 {
dek.copy_from_slice(&payload[0..32]);
None
} else {
continue;
};
matching = Some(UnlockedKeys(dek, slot.version, 0, None, carrier_node_id));
} else if slot.slot_id == 1 {
let mut dek_1 = Zeroizing::new([0u8; 32]);
let mut dek_0 = Zeroizing::new([0u8; 32]);
let (carrier_dek, carrier_node_id) = if payload.len() >= 72 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
let cid = i64::from_le_bytes(payload[64..72].try_into().unwrap());
(Some(dek_0), if cid > 0 { Some(cid) } else { None })
} else if payload.len() >= 64 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
(Some(dek_0), None)
} else if payload.len() >= 32 {
dek_1.copy_from_slice(&payload[0..32]);
(None, None)
} else {
continue;
};
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
}
}
}
}
matching
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
@@ -72,7 +158,25 @@ fn current_timestamp() -> u64 {
impl Database { impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas. /// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let conn = Connection::open(path)?; let path_ref = path.as_ref();
let conn = match Connection::open(path_ref) {
Ok(c) => c,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("Access is denied")
|| err_str.contains("permission denied")
|| err_str.contains("os error 5")
{
bail!(
"Zugriff auf '{}' verweigert (OS Fehler 5 / Access Denied).\n\
[!] Möglicherweise blockiert durch Windows Defender 'Überwachter Ordnerzugriff' (Controlled Folder Access).\n\
[i] Abhilfe: Fügen Sie 'sanctum.exe' in den Windows-Sicherheitseinstellungen (Viren- & Bedrohungsschutz -> Ransomware-Schutz -> Überwachter Ordnerzugriff) als erlaubte App hinzu, oder platzieren Sie den Container außerhalb geschützter Benutzerordner.",
path_ref.display()
);
}
return Err(e.into());
}
};
let db = Self { let db = Self {
conn: Arc::new(Mutex::new(conn)), conn: Arc::new(Mutex::new(conn)),
}; };
@@ -97,7 +201,13 @@ impl Database {
self.conn.lock().unwrap() self.conn.lock().unwrap()
} }
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalten slot_id und vault_id, auto_vacuum) durch. /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?;
Ok(meta.authenticate(password))
}
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
pub fn ensure_schema_upgrades(&self) -> Result<()> { pub fn ensure_schema_upgrades(&self) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0); let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
@@ -106,16 +216,9 @@ impl Database {
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
} }
// Spalte slot_id in meta // Spalte slot_id in meta (falls aus v1 migriert)
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []); let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
// Spalte vault_id in nodes
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_vault_parent ON nodes(vault_id, parent_id)", []);
// Spalte vault_id in chunks
let _ = conn.execute("ALTER TABLE chunks ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
Ok(()) Ok(())
} }
@@ -168,6 +271,34 @@ impl Database {
Ok(count as usize) Ok(count as usize)
} }
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
)?;
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
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();
@@ -195,21 +326,238 @@ impl Database {
Ok(()) Ok(())
} }
/// Prüft, ob ein Hidden Vault (Wurzelknoten 2 mit vault_id = 1) im Container existiert. /// Initialisiert das Datenbankschema für Modell A (Alibi-Carrier / Steganografischer Tresor).
pub fn has_hidden_vault(&self) -> Result<bool> { /// Legt die Trägerdatei im Decoy-Vault an und allokiert alle Carrier-Chunks mit initialem Rauschen.
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - Slot 0: 40 Bytes gewrappter Payload (32B DEK_0 || 8B carrier_node_id)
/// - Slot 1: 72 Bytes gewrappter Payload (32B DEK_1 || 32B DEK_0 || 8B carrier_node_id oder CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - 0 unzugeordnete Chunks: 100% aller Chunks gehören zu legitimen Decoy-Inodes und authentifizieren fehlerfrei unter DEK_0!
pub fn init_schema_with_carrier(
&self,
salt_0: &[u8; 16],
kdf_params_0: &KdfParams,
wrapped_dek_0: &[u8],
header_nonce_0: &[u8; 12],
header_tag_0: &[u8; 16],
carrier_config: Option<(
&str, // carrier_name
u64, // carrier_size_bytes
&[u8; 16], // salt_1
&KdfParams, // kdf_params_1
&[u8], // wrapped_dek_1 (72B)
&[u8; 12], // header_nonce_1
&[u8; 16], // header_tag_1
&[u8; 32], // raw DEK_0
&[u8; 32], // raw DEK_1
)>,
) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let exists: bool = conn
.query_row( conn.execute_batch(
"SELECT 1 FROM nodes WHERE id = 2 AND vault_id = 1 LIMIT 1", "CREATE TABLE IF NOT EXISTS meta (
[], slot_id INTEGER NOT NULL PRIMARY KEY,
|_| Ok(true), magic BLOB NOT NULL,
) version INTEGER NOT NULL,
.optional()? kdf_salt BLOB NOT NULL,
.unwrap_or(false); kdf_params TEXT NOT NULL,
Ok(exists) wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index),
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);",
)?;
// Slot 0 einfügen (Standard / Decoy Vault)
let params_json_0 = serde_json::to_string(kdf_params_0)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
salt_0.as_slice(),
params_json_0,
wrapped_dek_0,
header_nonce_0.as_slice(),
header_tag_0.as_slice(),
],
)?;
let now = current_timestamp();
// Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
let carrier_node_id = if let Some((
c_name,
c_size,
h_salt,
h_params,
h_wrapped,
h_nonce,
h_tag,
dek_0,
dek_1,
)) = carrier_config
{
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, ?1, 0, ?2, ?3, ?4)",
params![c_name, c_size as i64, now, now],
)?;
let c_id = conn.last_insert_rowid();
// Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten)
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32;
// Slot 1 (Hidden Vault) einfügen
let params_json_1 = serde_json::to_string(h_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
h_salt.as_slice(),
params_json_1,
h_wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)?;
// Initialisiere CarrierManifest für Block 0
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
let manifest_bytes = serde_json::to_vec(&manifest)?;
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
dek_1,
c_id,
0,
&manifest_bytes,
FORMAT_VERSION,
)?;
let inner_ct_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext);
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
outer_plaintext[12..28].copy_from_slice(&inner_tag);
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
let ct_end = 32 + inner_ct.len();
if ct_end > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 0");
}
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
0,
&outer_plaintext,
FORMAT_VERSION,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
)?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut dummy_noise);
conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 1..total_blocks {
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
b,
&dummy_noise,
FORMAT_VERSION,
)?;
chunk_stmt.execute(params![
c_id,
b,
nonce.as_slice(),
tag.as_slice(),
ct,
])?;
if b % 500 == 0 {
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
}
}
conn.execute_batch("COMMIT;")?;
Some(c_id)
} else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
dummy_salt.as_slice(),
dummy_params_json,
dummy_dek.as_slice(),
dummy_nonce.as_slice(),
dummy_tag.as_slice(),
],
)?;
None
};
Ok(carrier_node_id)
} }
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault). /// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - Keinerlei Klartext-Kennzeichnungen wie `vault_id` in SQLite
pub fn init_schema_with_hidden( pub fn init_schema_with_hidden(
&self, &self,
salt: &[u8; 16], salt: &[u8; 16],
@@ -235,7 +583,6 @@ impl Database {
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_id INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER, parent_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
is_dir INTEGER NOT NULL, is_dir INTEGER NOT NULL,
@@ -244,11 +591,10 @@ impl Database {
modified_at INTEGER NOT NULL, modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(vault_id, COALESCE(parent_id, 0), name); CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS chunks ( CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL, node_id INTEGER NOT NULL,
vault_id INTEGER NOT NULL DEFAULT 0,
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
tag BLOB NOT NULL, tag BLOB NOT NULL,
@@ -274,11 +620,16 @@ impl Database {
], ],
)?; )?;
// Wurzelknoten '/' für Vault 0 (id = 1, vault_id = 0) anlegen // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
let now = current_timestamp(); let now = current_timestamp();
conn.execute( conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)", VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, NULL, '', 1, 0, ?1, ?2)",
params![now, now], params![now, now],
)?; )?;
@@ -298,13 +649,6 @@ impl Database {
h_tag.as_slice(), h_tag.as_slice(),
], ],
)?; )?;
// Wurzelknoten '/' für Hidden Vault (id = 2, vault_id = 1) anlegen
conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, 1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
} else { } else {
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie // Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
@@ -509,7 +853,7 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = 0 AND parent_id = ?1 AND name = ?2", WHERE parent_id = ?1 AND name = ?2",
)?; )?;
let record: Option<NodeRecord> = stmt let record: Option<NodeRecord> = stmt
@@ -541,7 +885,7 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = 1 AND parent_id = ?1", WHERE parent_id = ?1",
)?; )?;
let rows = stmt.query_map(params![current_id], |row| { let rows = stmt.query_map(params![current_id], |row| {
@@ -559,7 +903,7 @@ impl Database {
let mut matched_record = None; let mut matched_record = None;
for r in rows { for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name = decrypt_node_name(dek, &enc_name).unwrap_or(enc_name); let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
if dec_name == *segment { if dec_name == *segment {
matched_record = Some(NodeRecord { matched_record = Some(NodeRecord {
id, id,
@@ -604,14 +948,15 @@ impl Database {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes WHERE id = ?1 AND vault_id = ?2", FROM nodes WHERE id = ?1",
)?; )?;
let record = stmt let record = stmt
.query_row(params![id, vault_id], |row| { .query_row(params![id], |row| {
let enc_name: String = row.get(2)?; let enc_name: String = row.get(2)?;
let p_id: Option<i64> = row.get(1)?;
let name = if vault_id == 1 { let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
} else { } else {
enc_name enc_name
}; };
@@ -646,14 +991,14 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = ?1 AND parent_id = ?2 WHERE parent_id = ?1
ORDER BY is_dir DESC, id ASC", ORDER BY is_dir DESC, id ASC",
)?; )?;
let rows = stmt.query_map(params![vault_id, parent_id], |row| { let rows = stmt.query_map(params![parent_id], |row| {
let enc_name: String = row.get(2)?; let enc_name: String = row.get(2)?;
let name = if vault_id == 1 { let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
} else { } else {
enc_name enc_name
}; };
@@ -694,15 +1039,15 @@ impl Database {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 { let stored_name = if vault_id == 1 {
encrypt_node_name(dek, name) encrypt_node_name(dek, parent_id, name)
} else { } else {
name.to_string() name.to_string()
}; };
conn.execute( conn.execute(
"INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at) "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now], params![parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
)?; )?;
let new_id = conn.last_insert_rowid(); let new_id = conn.last_insert_rowid();
@@ -776,7 +1121,7 @@ impl Database {
let now = current_timestamp(); let now = current_timestamp();
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 { let stored_name = if vault_id == 1 {
encrypt_node_name(dek, new_name) encrypt_node_name(dek, new_parent_id, new_name)
} else { } else {
new_name.to_string() new_name.to_string()
}; };
@@ -855,6 +1200,43 @@ impl Database {
Ok(()) 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) /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
@@ -904,7 +1286,66 @@ impl Database {
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> { pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; let _res: (i64, i64, i64) = conn.query_row(
"PRAGMA wal_checkpoint(TRUNCATE);",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
Ok(())
}
/// Erstellt ein konsistentes Online-Live-Backup der gesamten Container-Datenbank via SQLite Online Backup API.
/// Kann auch während eines aktiven WebDAV-Mounts ohne Lese-/Schreibkonflikte ausgeführt werden.
pub fn online_backup<P: AsRef<Path>>(&self, dest_path: P) -> Result<()> {
let dest_path = dest_path.as_ref();
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut dest_conn = Connection::open(dest_path)?;
let src_conn = self.conn.lock().unwrap();
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
drop(backup);
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
/// Stellt einen Container vollständig aus einer Sicherungskopie wieder her und verifiziert die Konsistenz.
pub fn restore_from_backup<P: AsRef<Path>>(backup_path: P, dest_path: P) -> Result<()> {
let backup_path = backup_path.as_ref();
let dest_path = dest_path.as_ref();
if !backup_path.exists() {
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let src_conn = Connection::open(backup_path)?;
let mut dest_conn = Connection::open(dest_path)?;
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
drop(backup);
drop(src_conn);
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
// B-Tree Integritätsprüfung
let check: String = dest_conn.query_row("PRAGMA quick_check;", [], |r| r.get(0))?;
if check != "ok" {
bail!("Integritätsprüfung des wiederhergestellten Containers fehlgeschlagen: {check}");
}
Ok(()) Ok(())
} }
@@ -927,8 +1368,12 @@ impl Database {
conn.execute("DELETE FROM meta", [])?; conn.execute("DELETE FROM meta", [])?;
let mut has_slot1 = false;
if !meta.slots.is_empty() { if !meta.slots.is_empty() {
for slot in &meta.slots { for slot in &meta.slots {
if slot.slot_id == 1 {
has_slot1 = true;
}
let params_json = serde_json::to_string(&slot.kdf_params)?; let params_json = serde_json::to_string(&slot.kdf_params)?;
conn.execute( conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
@@ -960,8 +1405,10 @@ impl Database {
meta.header_tag.as_slice(), meta.header_tag.as_slice(),
], ],
)?; )?;
}
// Dummy-Slot 1 für Plausible Deniability generieren // Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
if !has_slot1 {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?; let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute( conn.execute(
@@ -1234,8 +1681,6 @@ mod tests {
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
).unwrap(); ).unwrap();
assert!(db.has_hidden_vault().unwrap());
// Slots prüfen // Slots prüfen
let slots = db.read_slots().unwrap(); let slots = db.read_slots().unwrap();
assert_eq!(slots.len(), 2); assert_eq!(slots.len(), 2);
@@ -1268,7 +1713,7 @@ mod tests {
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap(); let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!"); assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabelle `nodes` // Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
let conn = db.conn.lock().unwrap(); let conn = db.conn.lock().unwrap();
let raw_name_v0: String = conn.query_row( let raw_name_v0: String = conn.query_row(
"SELECT name FROM nodes WHERE id = ?1", "SELECT name FROM nodes WHERE id = ?1",
@@ -1282,7 +1727,67 @@ mod tests {
params![hidden_file.id], params![hidden_file.id],
|r| r.get(0), |r| r.get(0),
).unwrap(); ).unwrap();
assert!(raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname muss verschlüsselt mit $h$ beginnen"); // Plausible Deniability: Kein $h$-Präfix, kein Klartext
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
// Keine Spalte `vault_id` in nodes oder chunks
let has_vault_id_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
let has_vault_id_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).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);
} }
} }
+63 -5
View File
@@ -110,7 +110,7 @@ pub fn verify_container(
node_map.insert(node.id, node.clone()); node_map.insert(node.id, node.clone());
} }
// Root-Knoten prüfen (id = 1) // Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault)
match node_map.get(&1) { match node_map.get(&1) {
Some(root) => { Some(root) => {
if !root.is_dir { if !root.is_dir {
@@ -125,9 +125,18 @@ pub fn verify_container(
} }
} }
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes { for node in &all_nodes {
if node.id == 1 { if node.id == 1 || node.id == 2 {
continue; continue;
} }
@@ -174,6 +183,54 @@ pub fn verify_container(
} }
} }
// Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1)
let mut vault0_nodes = HashSet::new();
let mut vault1_nodes = HashSet::new();
vault0_nodes.insert(1i64);
if node_map.contains_key(&2) {
vault1_nodes.insert(2i64);
}
let mut changed = true;
while changed {
changed = false;
for node in &all_nodes {
if let Some(pid) = node.parent_id {
if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) {
vault0_nodes.insert(node.id);
changed = true;
} else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) {
vault1_nodes.insert(node.id);
changed = true;
}
}
}
}
// Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört
let active_vault_nodes = if let Some(active_dek) = dek {
let is_vault1 = {
let mut found_v1 = false;
for node in &all_nodes {
if node.parent_id == Some(2) {
if crate::crypto::decrypt_node_name(active_dek, 2, &node.name).is_some() {
found_v1 = true;
break;
}
}
}
found_v1
};
if is_vault1 {
Some(&vault1_nodes)
} else {
Some(&vault0_nodes)
}
} else {
None
};
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers() let chunk_headers = db.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?; .context("Fehler beim Abrufen der Chunk-Liste")?;
@@ -187,8 +244,9 @@ pub fn verify_container(
)); ));
} }
if let Some(active_dek) = dek { if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
if full_chunks { // Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault)
if target_nodes.contains(&node_id) && full_chunks {
match db.read_chunk(node_id, chunk_index) { match db.read_chunk(node_id, chunk_index) {
Ok(Some(record)) => { Ok(Some(record)) => {
match decrypt_chunk( match decrypt_chunk(
@@ -283,7 +341,7 @@ mod tests {
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1); assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 2); // Root + photos assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_chunks, 2); assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0); assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0); assert_eq!(report.orphan_nodes, 0);
+327 -37
View File
@@ -14,8 +14,9 @@ use dav_server::{
}; };
use futures_util::stream; use futures_util::stream;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use zeroize::Zeroizing; use zeroize::{Zeroize, Zeroizing};
use crate::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord}; use crate::storage::{Database, NodeRecord};
@@ -134,6 +135,7 @@ impl SanctumFile {
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32, format_version: u32,
last_activity: Arc<AtomicU64>, last_activity: Arc<AtomicU64>,
append: bool,
) -> Self { ) -> Self {
let meta = SanctumMetaData { let meta = SanctumMetaData {
is_dir: node.is_dir, is_dir: node.is_dir,
@@ -142,10 +144,12 @@ impl SanctumFile {
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
}; };
let cursor = if append { node.size } else { 0 };
Self { Self {
node_id: node.id, node_id: node.id,
file_size: node.size, file_size: node.size,
cursor: 0, cursor,
db, db,
dek, dek,
meta, meta,
@@ -164,8 +168,15 @@ impl SanctumFile {
} }
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
fn flush_cached_chunk(&mut self) -> Result<(), FsError> { /// 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 { if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) = let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
@@ -173,16 +184,35 @@ impl SanctumFile {
FsError::GeneralFailure FsError::GeneralFailure
})?; })?;
self.db if let Err(e) = self.db.write_chunk_and_update_size(
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext) self.node_id,
.map_err(|e| { idx,
error!("DB-Fehler beim Schreiben des Chunks: {e}"); &nonce,
FsError::GeneralFailure &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 { if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = false; *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(()) Ok(())
} }
@@ -195,7 +225,10 @@ impl SanctumFile {
}; };
if !is_current { if !is_current {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize();
}
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| { let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}"); error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
@@ -229,14 +262,12 @@ impl SanctumFile {
impl Drop for SanctumFile { impl Drop for SanctumFile {
fn drop(&mut self) { 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); warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
} }
let now = SystemTime::now() if let Some((_, ref mut data, _)) = self.cached_chunk {
.duration_since(UNIX_EPOCH) data.zeroize();
.map(|d| d.as_secs()) }
.unwrap_or(0);
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
} }
} }
@@ -324,7 +355,7 @@ impl DavFile for SanctumFile {
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen // 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) { 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..]; src = &src[to_write..];
@@ -360,19 +391,7 @@ impl DavFile for SanctumFile {
fn flush(&mut self) -> FsFuture<'_, ()> { fn flush(&mut self) -> FsFuture<'_, ()> {
self.touch(); self.touch();
Box::pin(async move { Box::pin(async move {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
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);
Ok(()) Ok(())
}) })
} }
@@ -386,6 +405,10 @@ impl DavFile for SanctumFile {
pub struct SanctumFs { pub struct SanctumFs {
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
#[allow(dead_code)]
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
carrier_node_id: Option<i64>,
carrier_fs: Option<CarrierFs>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
@@ -412,14 +435,71 @@ impl SanctumFs {
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
) -> Self {
Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id)
}
pub fn with_carrier(
db: Database,
dek: Zeroizing<[u8; 32]>,
carrier_dek: Option<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self { ) -> Self {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
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(
db.clone(),
c_nid,
c_dek.clone(),
dek_arc.clone(),
format_version,
anti_leak,
) {
Ok(cfs) => Some(cfs),
Err(e) => {
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
None
}
}
} else {
None
}
} else {
None
};
// Forensischer RAM-Paging-Schutz via VirtualLock (verhindert Auslagerung in pagefile.sys)
crate::windows::lock_memory(dek_arc.as_ptr(), 32);
if let Some(ref c_dek) = carrier_dek_arc {
crate::windows::lock_memory(c_dek.as_ptr(), 32);
}
Self { Self {
db, db,
dek: Arc::new(dek), dek: dek_arc,
carrier_dek: carrier_dek_arc,
carrier_node_id,
carrier_fs,
format_version, format_version,
anti_leak, anti_leak,
vault_id, vault_id,
@@ -432,20 +512,28 @@ impl SanctumFs {
} }
pub fn last_activity(&self) -> Arc<AtomicU64> { pub fn last_activity(&self) -> Arc<AtomicU64> {
if let Some(ref cfs) = self.carrier_fs {
cfs.last_activity()
} else {
self.last_activity.clone() self.last_activity.clone()
} }
}
pub fn is_anti_leak_enabled(&self) -> bool { pub fn is_anti_leak_enabled(&self) -> bool {
self.anti_leak self.anti_leak
} }
pub fn touch(&self) { pub fn touch(&self) {
if let Some(ref cfs) = self.carrier_fs {
cfs.touch();
} else {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
self.last_activity.store(now, Ordering::Relaxed); self.last_activity.store(now, Ordering::Relaxed);
} }
}
fn path_to_str(path: &DavPath) -> String { fn path_to_str(path: &DavPath) -> String {
String::from_utf8_lossy(path.as_bytes()).to_string() String::from_utf8_lossy(path.as_bytes()).to_string()
@@ -460,6 +548,7 @@ impl SanctumFs {
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> { fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
validate_path_safety(path)?;
self.db self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek) .resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
@@ -472,6 +561,7 @@ impl SanctumFs {
} }
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> { fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
validate_path_safety(name)?;
self.db self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| { .map_err(|e| {
@@ -481,18 +571,45 @@ impl SanctumFs {
} }
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
validate_path_safety(new_name)?;
self.db self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .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);
if let Some(ref c_dek) = self.carrier_dek {
crate::windows::unlock_memory(c_dek.as_ptr(), 32);
}
}
}
impl DavFileSystem for SanctumFs { impl DavFileSystem for SanctumFs {
fn open<'a>( fn open<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
options: OpenOptions, options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> { ) -> FsFuture<'a, Box<dyn DavFile>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.open(path, options);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let (parent_path, file_name) = self.split_parent_and_name(&path_str); let (parent_path, file_name) = self.split_parent_and_name(&path_str);
@@ -520,6 +637,11 @@ impl DavFileSystem for SanctumFs {
let node = match existing_node { let node = match existing_node {
Some(n) => { Some(n) => {
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
return Err(FsError::Forbidden);
}
if n.is_dir && (options.write || options.append) { if n.is_dir && (options.write || options.append) {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -571,6 +693,7 @@ impl DavFileSystem for SanctumFs {
self.dek.clone(), self.dek.clone(),
self.format_version, self.format_version,
self.last_activity.clone(), self.last_activity.clone(),
options.append,
); );
Ok(Box::new(file) as Box<dyn DavFile>) Ok(Box::new(file) as Box<dyn DavFile>)
}) })
@@ -579,10 +702,13 @@ impl DavFileSystem for SanctumFs {
fn read_dir<'a>( fn read_dir<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
_meta: ReadDirMeta, meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.read_dir(path, meta);
}
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
@@ -615,11 +741,12 @@ impl DavFileSystem for SanctumFs {
} }
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.metadata(path);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -636,10 +763,17 @@ impl DavFileSystem for SanctumFs {
} }
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.symlink_metadata(path);
}
self.metadata(path) self.metadata(path)
} }
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.create_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -668,6 +802,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -684,6 +822,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)?;
@@ -693,6 +838,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_file(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -704,6 +853,11 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Löschen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -717,6 +871,10 @@ impl DavFileSystem for SanctumFs {
from: &'a DavPath, from: &'a DavPath,
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
@@ -726,6 +884,11 @@ impl DavFileSystem for SanctumFs {
.resolve_path(&from_str)? .resolve_path(&from_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) { if self.anti_leak && is_leak_file(to_name) {
@@ -745,6 +908,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)?;
@@ -780,6 +947,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)?;
@@ -1003,4 +1177,120 @@ 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)
));
}
#[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());
}
} }
+163
View File
@@ -419,10 +419,164 @@ pub fn start_session_lock_monitor(
Ok(SessionLockGuard) Ok(SessionLockGuard)
} }
// ─────────────────────────────────────────────────────────────
// Console Close Event Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
// ─────────────────────────────────────────────────────────────
#[cfg(windows)]
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
#[cfg(windows)]
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
const CTRL_C_EVENT: u32 = 0;
const CTRL_BREAK_EVENT: u32 = 1;
const CTRL_CLOSE_EVENT: u32 = 2;
const CTRL_LOGOFF_EVENT: u32 = 5;
const CTRL_SHUTDOWN_EVENT: u32 = 6;
match ctrl_type {
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
if let Some(dl) = *guard {
let drive_str = format!("{}:", dl.to_ascii_uppercase());
let _ = std::process::Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output();
}
}
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
if let Ok(guard) = CONSOLE_CTRL_TX.lock() {
if let Some(ref tx) = *guard {
let _ = tx.blocking_send(());
}
}
1 // TRUE: Event abgearbeitet
}
CTRL_C_EVENT | CTRL_BREAK_EVENT => {
// Ctrl+C wird primär von tokio::signal::ctrl_c() behandelt
0
}
_ => 0,
}
}
/// RAII Guard zur sauberen Deregistrierung des Win32 Console-Control-Handlers.
pub struct ConsoleCtrlGuard;
impl Drop for ConsoleCtrlGuard {
fn drop(&mut self) {
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe {
SetConsoleCtrlHandler(Some(console_ctrl_routine), 0);
}
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
*guard = None;
}
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
*guard = None;
}
}
}
}
/// Registriert einen Win32 Console Control Handler für CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT
/// und CTRL_SHUTDOWN_EVENT, um verwaiste Netzlaufwerke beim Schließen des Konsolenfensters zu verhindern.
pub fn start_console_ctrl_monitor(
shutdown_tx: tokio::sync::mpsc::Sender<()>,
drive_letter: char,
) -> Result<ConsoleCtrlGuard> {
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
*guard = Some(shutdown_tx);
}
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
*guard = Some(drive_letter);
}
let res = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_routine), 1) };
if res == 0 {
bail!("Konnte Win32 SetConsoleCtrlHandler nicht registrieren");
}
Ok(ConsoleCtrlGuard)
}
#[cfg(not(windows))]
{
let _ = shutdown_tx;
let _ = drive_letter;
Ok(ConsoleCtrlGuard)
}
}
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys).
pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualLock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualLock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(not(windows))]
{
let _ = (ptr, len);
false
}
}
/// Entriegelt einen zuvor mit `lock_memory` geschützten Speicherbereich im RAM.
pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualUnlock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualUnlock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(not(windows))]
{
let _ = (ptr, len);
false
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_virtual_lock_memory_lifecycle() {
let buffer = vec![0x42u8; 4096];
let _ = lock_memory(buffer.as_ptr(), buffer.len());
let _ = unlock_memory(buffer.as_ptr(), buffer.len());
assert_eq!(buffer[0], 0x42);
}
#[test] #[test]
fn test_find_next_available_drive() { fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive"); let drive = find_next_available_drive().expect("Find next drive");
@@ -438,4 +592,13 @@ mod tests {
// Dropping monitor closes message loop and joins thread cleanly // Dropping monitor closes message loop and joins thread cleanly
drop(monitor); drop(monitor);
} }
#[test]
fn test_console_ctrl_monitor_lifecycle() {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let monitor = start_console_ctrl_monitor(tx, 'Z');
assert!(monitor.is_ok());
drop(monitor);
} }
}
+472
View File
@@ -0,0 +1,472 @@
use std::path::PathBuf;
use bytes::Bytes;
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use sanctum::vfs::SanctumFs;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id));
path
}
#[tokio::test]
async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let path = temp_db_path("carrier_accounting");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB)
let carrier_name = "system_backup.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
let created_cid = db
.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
assert_eq!(created_cid, Some(carrier_node_id));
// 1. Authentifizierung beider Passwörter
let meta = db.read_meta().unwrap();
let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.slot_id(), 0);
assert_eq!(**auth_decoy.dek(), *dek_0);
assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id));
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.slot_id(), 1);
assert_eq!(**auth_hidden.dek(), *dek_1);
assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0);
assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id));
// 2. Decoy Mount: Schutz der Alibi-Datei (system_backup.dat)
let decoy_fs = SanctumFs::with_carrier(
db.clone(),
auth_decoy.dek().clone(),
auth_decoy.carrier_dek(),
auth_decoy.carrier_node_id(),
auth_decoy.version(),
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// Metadaten der Alibi-Datei im Decoy prüfen
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
assert_eq!(carrier_meta.len(), carrier_size_bytes);
assert!(!carrier_meta.is_dir());
// Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden
let write_opts = OpenOptions {
write: true,
..Default::default()
};
assert!(
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
assert!(
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht gelöscht werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
let new_name = DavPath::new("/renamed.iso").unwrap();
assert!(
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht umbenannt werden"
);
// Alibi-Datei KANN im Decoy-Mount gelesen werden
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
assert_eq!(first_mb.len(), CHUNK_SIZE);
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer)
let root_path = DavPath::new("/").unwrap();
let mut stream = hidden_fs
.read_dir(&root_path, ReadDirMeta::None)
.await
.expect("Read dir root");
use futures_util::StreamExt;
let mut entries = Vec::new();
while let Some(item) = stream.next().await {
entries.push(item.unwrap().name());
}
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
// Ordner erstellen
let secret_dir = DavPath::new("/Classified").unwrap();
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
// Datei im Ordner anlegen und schreiben
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
let create_opts = OpenOptions {
create: true,
write: true,
..Default::default()
};
let mut secret_file = hidden_fs
.open(&secret_file_path, create_opts)
.await
.expect("Create secret file");
let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault";
secret_file
.write_bytes(Bytes::from_static(secret_content))
.await
.expect("Write secret content");
secret_file.flush().await.expect("Flush secret file");
drop(secret_file);
// Datei lesen und verifizieren
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut read_handle = hidden_fs
.open(&secret_file_path, read_opts)
.await
.expect("Open secret file for read");
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
assert_eq!(&read_data[..], secret_content);
drop(read_handle);
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
let mut big_file = hidden_fs
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Create big file");
let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes
let mut payload = vec![0u8; payload_size];
OsRng.fill_bytes(&mut payload);
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
big_file.flush().await.expect("Flush big file");
drop(big_file);
// Datei zurücklesen und Bit-für-Bit verifizieren
let mut read_big = hidden_fs
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open big file");
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
assert_eq!(read_big_bytes.len(), payload_size);
assert_eq!(&read_big_bytes[..], &payload[..]);
drop(read_big);
// Datei umbenennen
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
// Datei löschen (Blöcke werden geshreddert und freigegeben)
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
// Checkpoint SQLite
db.checkpoint().unwrap();
// 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG
// Ein Angreifer besitzt nur das Decoy-Passwort (dek_0).
// Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch.
// ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln!
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
assert!(
report.is_healthy(),
"Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}",
report.errors
);
assert_eq!(
report.corrupted_chunks, 0,
"Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!"
);
assert_eq!(
report.orphan_nodes, 0,
"Es darf keine verwaisten Knoten geben!"
);
// Aufräumen
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_model_a_container_file_size_invariance() {
let path = temp_db_path("carrier_size_invariance");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"virtual_disk.vhdx",
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
// Initiale Dateigröße messen
let initial_file_size = std::fs::metadata(&path).unwrap().len();
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
let mut handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file");
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
OsRng.fill_bytes(&mut random_data);
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
handle.flush().await.expect("Flush 4MB");
drop(handle);
db.checkpoint().unwrap();
// Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen
let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len();
// Die Dateigröße auf der Festplatte DARF NICHT WACHSEN!
// Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben.
assert_eq!(
initial_file_size, size_after_hidden_writes,
"Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}",
initial_file_size, size_after_hidden_writes
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_carrier_file_drop_and_append_mode() {
let path = temp_db_path("carrier_drop_append");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let carrier_name = "test_carrier.iso";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "HiddenSecretPassword2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
let mut write_handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file for write");
let initial_data = b"Hello from unflushed write!";
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
// WICHTIG: KEIN write_handle.flush()! Nur drop:
drop(write_handle);
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
let mut read_handle = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read");
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
drop(read_handle);
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
let append_data = b" - Appended data at EOF!";
let mut append_handle = hidden_fs
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
.await
.expect("Open file for append");
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
drop(append_handle); // Drop sichert auch hier
// Prüfe den vollständigen Dateiinhalt nach Append
let mut read_handle_2 = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read after append");
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
let mut expected = Vec::new();
expected.extend_from_slice(initial_data);
expected.extend_from_slice(append_data);
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
drop(read_handle_2);
let _ = std::fs::remove_file(&path);
}
+371
View File
@@ -792,6 +792,377 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
let _ = std::fs::remove_file(&container_path); let _ = std::fs::remove_file(&container_path);
} }
#[tokio::test]
async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() {
let temp_dir = std::env::temp_dir();
let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id()));
let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id()));
if path_standard.exists() {
let _ = std::fs::remove_file(&path_standard);
}
if path_dual.exists() {
let _ = std::fs::remove_file(&path_dual);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let pass_decoy = "OuterDecoyPassphrase2026!";
let pass_hidden = "InnerHiddenVaultPass2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
// 1. Erstelle Standard-Container (Container A)
let salt_std = generate_salt();
let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap();
let dek_std = generate_dek();
let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap();
let db_std = Database::open(&path_standard).unwrap();
db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap();
db_std.checkpoint().unwrap();
// 2. Erstelle Dual-Vault Container (Container B)
let salt_b0 = generate_salt();
let salt_b1 = generate_salt();
let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap();
let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap();
let dek_b0 = generate_dek();
let dek_b1 = generate_dek();
let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap();
let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap();
let db_dual = Database::open(&path_dual).unwrap();
db_dual.init_schema_with_hidden(
&salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0,
Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)),
).unwrap();
db_dual.checkpoint().unwrap();
// 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit
let conn_std = rusqlite::Connection::open(&path_standard).unwrap();
let conn_dual = rusqlite::Connection::open(&path_dual).unwrap();
// A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern
for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] {
let v_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name);
let v_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name);
// Genau 2 Slots in meta
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap();
assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name);
// Genau 2 Root-Nodes in nodes (id=1 und id=2)
let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap();
assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name);
}
drop(conn_std);
drop(conn_dual);
// 4. Dateien in beiden Vaults von Container B anlegen
let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0);
let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1);
let decoy_path = DavPath::new("/family_recipe.txt").unwrap();
let hidden_path = DavPath::new("/private_journal.docx").unwrap();
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap();
f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap();
f_d.flush().await.unwrap();
drop(f_d);
let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap();
f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap();
f_h.flush().await.unwrap();
drop(f_h);
db_dual.checkpoint().unwrap();
// 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite
let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap();
let hidden_node_name: String = conn_dual2.query_row(
"SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0)
).unwrap();
assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!");
assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!");
assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein");
let count_dollar_h: i64 = conn_dual2.query_row(
"SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0)
).unwrap();
assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren");
drop(conn_dual2);
// 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG
let meta_dual = db_dual.read_meta().unwrap();
let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren");
assert_eq!(*auth_decoy.0, *dek_b0);
let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren");
assert_eq!(*auth_hidden.0, *dek_b1);
assert!(meta_dual.authenticate("WrongPassword123!").is_none());
// 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT
// Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden!
let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap();
assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors);
assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden");
assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden");
// Hidden-Prüfung: Ebenfalls gesund!
let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap();
assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors);
assert_eq!(report_hidden.corrupted_chunks, 0);
assert_eq!(report_hidden.orphan_nodes, 0);
// 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten!
export_header_backup(&path_dual, &backup_path).unwrap();
// Zerstöre Header
let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap();
conn_wipe.execute("DELETE FROM meta", []).unwrap();
drop(conn_wipe);
// Stelle wieder her
restore_header_backup(&path_dual, &backup_path).unwrap();
// Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos!
let restored_meta = db_dual.read_meta().unwrap();
let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore");
assert_eq!(res_decoy.2, 0);
let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore");
assert_eq!(res_hidden.2, 1);
// 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört!
let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap();
let brand_new_decoy_pass = "BrandNewDecoyPassword2026!";
restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap();
let rescued_meta = db_dual.read_meta().unwrap();
// Neues Decoy Passwort entsperrt Slot 0
let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass");
assert_eq!(rescued_decoy.2, 0);
assert_eq!(*rescued_decoy.0, *dek_b0);
// Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!)
let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved");
assert_eq!(rescued_hidden.2, 1);
assert_eq!(*rescued_hidden.0, *dek_b1);
// Aufräumen
let _ = std::fs::remove_file(&path_standard);
let _ = std::fs::remove_file(&path_dual);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_sanctum_online_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_backup_src_{}.sanctum", std::process::id()));
let backup_path = temp_dir.join(format!("test_backup_out_{}.sanctum.bak", std::process::id()));
let restored_path = temp_dir.join(format!("test_backup_restored_{}.sanctum", std::process::id()));
for p in [&container_path, &backup_path, &restored_path] {
if p.exists() {
let _ = std::fs::remove_file(p);
}
}
let password = "BackupTestPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
// Datei schreiben
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/important.txt").unwrap();
let mut file = fs.open(&test_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")).await.unwrap();
file.flush().await.unwrap();
drop(file);
// 1. Online-Live-Backup erstellen
db.online_backup(&backup_path).expect("Online backup should succeed");
assert!(backup_path.exists(), "Backup-Datei muss existieren");
// 2. Original-Container verändern (neue Datei hinzufügen)
let extra_file = DavPath::new("/extra_after_backup.txt").unwrap();
let mut file2 = fs.open(&extra_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file2.write_bytes(Bytes::from_static(b"After Backup Data")).await.unwrap();
file2.flush().await.unwrap();
drop(file2);
// 3. Restore aus dem Backup in neuen Pfad
Database::restore_from_backup(&backup_path, &restored_path).expect("Restore should succeed");
assert!(restored_path.exists(), "Wiederhergestellter Container muss existieren");
// 4. Verifiziere den wiederhergestellten Container
let restored_db = Database::open(&restored_path).unwrap();
let meta = restored_db.read_meta().unwrap();
let auth = meta.authenticate(password).expect("Passwort muss den wiederhergestellten Container entsperren");
assert_eq!(*auth.0, *dek);
let restored_fs = SanctumFs::new(restored_db.clone(), auth.0, meta.version);
// /important.txt muss existieren und den korrekten Inhalt haben
let mut read_handle = restored_fs.open(&test_file, OpenOptions { read: true, ..Default::default() }).await.unwrap();
let content = read_handle.read_bytes(100).await.unwrap();
assert_eq!(&content[..], b"Sanctum Online Backup Test Data");
drop(read_handle);
// /extra_after_backup.txt darf im Backup-Zustand NICHT existieren
assert!(restored_fs.open(&extra_file, OpenOptions { read: true, ..Default::default() }).await.is_err());
// Integritätsprüfung (FSCK) auf wiederhergestelltem Container
let report = verify_container(&restored_path, Some(&dek), true).unwrap();
assert!(report.is_healthy());
for p in [&container_path, &backup_path, &restored_path] {
let _ = std::fs::remove_file(p);
}
}
#[tokio::test]
async fn test_webdav_loopback_session_token_and_host_validation() {
use dav_server::{fakels::FakeLs, DavHandler};
use sanctum::mount::{is_loopback_host, serve_webdav_loop};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
// 1. Host-Header Unit Validierung
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8443"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:8443"));
assert!(!is_loopback_host("attacker.com"));
assert!(!is_loopback_host("evil.attacker.com:8443"));
assert!(!is_loopback_host("192.168.1.100"));
assert!(!is_loopback_host("internal.corp"));
// 2. Container und WebDAV Setup
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_webdav_sec_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "WebDavSecPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let session_token = "deadbeefcafebabe0123456789abcdef";
let token_path_prefix = format!("/{}", session_token);
let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let server_handle = tokio::spawn(serve_webdav_loop(
listener,
dav_server,
token_path_prefix.clone(),
shutdown_rx,
));
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 4. Angriffstest B: DNS-Rebinding mit fremdem Host-Header trotz erratenem Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET /{}/ HTTP/1.1\r\nHost: evil.attacker.com:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage mit fremdem Host-Header (DNS Rebinding) muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 200 OK"),
"Legitime OPTIONS-Anfrage mit Session-Token muss 200 OK liefern, erhalten:\n{}",
resp_str
);
assert!(
resp_str.to_lowercase().contains("dav:"),
"Antwort muss WebDAV DAV-Header enthalten"
);
}
// Server ordnungsgemäß beenden & aufräumen
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
let _ = std::fs::remove_file(&container_path);
}