Compare commits
10
Commits
v0.2.0
..
99813ae2a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99813ae2a3 | ||
|
|
90cf4927af | ||
|
|
abf395627a | ||
|
|
fcd59dfe68 | ||
|
|
a8fdfc674e | ||
|
|
70d03df676 | ||
|
|
771c08ff0f | ||
|
|
98bfac718f | ||
|
|
030ce6a1e5 | ||
|
|
b8e4dcb614 |
@@ -5,6 +5,36 @@ 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.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
@@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
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"] }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -168,6 +168,27 @@ 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. **DFIR ShellBag-Schutz**: Unterdrückt das automatische Öffnen des Windows Explorers für den Hidden Vault, um zu verhindern, dass vertrauliche Ordnernamen persistent in `UsrClass.dat` protokolliert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 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 30–50% steigern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,7 +201,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Erzeugt:
|
Erzeugt:
|
||||||
- `dist/sanctum-v0.2.0-windows-x86_64.zip`
|
- `dist/sanctum-v0.3.0-windows-x86_64.zip`
|
||||||
- `dist/SHA256SUMS.txt`
|
- `dist/SHA256SUMS.txt`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+294
-45
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,8 +379,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 +436,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 +552,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();
|
||||||
@@ -481,23 +629,124 @@ mod tests {
|
|||||||
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,3 +1,4 @@
|
|||||||
|
pub mod carrier;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
|||||||
+283
-103
@@ -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
|
||||||
@@ -107,6 +119,28 @@ enum Commands {
|
|||||||
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
|
||||||
@@ -131,6 +165,10 @@ enum Commands {
|
|||||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
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 +210,30 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +246,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 +315,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 +350,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 +376,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 +406,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 +505,16 @@ 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...");
|
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
|
||||||
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||||
(d, 0)
|
(d, 0, None, None)
|
||||||
} else {
|
} else {
|
||||||
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
let old_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des aktuellen Passworts")?;
|
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 +526,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 +561,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 +630,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 │");
|
||||||
@@ -546,24 +718,28 @@ fn handle_restore_header(
|
|||||||
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 if let Some(key) = recovery_key {
|
||||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel...");
|
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
|
||||||
let new_password = rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
let new_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
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, &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 {
|
} else {
|
||||||
@@ -585,24 +761,20 @@ 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));
|
let slot_id = keys.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 {
|
if slot_id == 1 {
|
||||||
@@ -624,27 +796,21 @@ 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) {
|
let slot_id = keys.slot_id();
|
||||||
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
|
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
|
||||||
found = Some(d);
|
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc));
|
||||||
break;
|
Some(keys.dek().clone())
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match found {
|
|
||||||
Some(d) => {
|
|
||||||
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"));
|
|
||||||
Some(d)
|
|
||||||
}
|
}
|
||||||
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 +836,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,
|
||||||
@@ -700,14 +877,16 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let auth = if let Some(key) = recovery_key {
|
let auth = if let Some(key) = recovery_key {
|
||||||
ContainerAuth::RecoveryKey(key)
|
ContainerAuth::RecoveryKey(Zeroizing::new(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)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -747,8 +926,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)?;
|
||||||
|
|||||||
+239
-80
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,29 +139,17 @@ 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...");
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
||||||
let mut unwrapped = None;
|
match meta.authenticate(password) {
|
||||||
|
Some(keys) => {
|
||||||
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 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");
|
||||||
@@ -142,23 +162,35 @@ pub async fn mount_container(
|
|||||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||||
|
|
||||||
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();
|
||||||
@@ -190,58 +222,35 @@ pub async fn mount_container(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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 (mit DFIR ShellBag-Schutz für Hidden Vault)
|
||||||
if open_explorer {
|
if open_explorer {
|
||||||
|
if vault_id == 1 {
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
ui::dim("OpSec-Schutz: Explorer-Auto-Open für Hidden Vault unterdrückt (verhindert persistente ShellBag-Spuren in UsrClass.dat).")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
let _ = crate::windows::open_in_explorer(drive_letter);
|
let _ = crate::windows::open_in_explorer(drive_letter);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// System-Tray Initialisierung
|
// System-Tray Initialisierung
|
||||||
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||||
@@ -322,7 +331,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 {
|
if vault_id == 1 {
|
||||||
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
|
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
|
||||||
}
|
}
|
||||||
@@ -342,13 +351,21 @@ pub async fn mount_container(
|
|||||||
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;
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
|
_ = console_close_rx.recv() => {
|
||||||
|
println!();
|
||||||
|
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||||
|
}
|
||||||
_ = tray_shutdown_rx.recv() => {
|
_ = tray_shutdown_rx.recv() => {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||||
@@ -403,3 +420,145 @@ pub async fn mount_container(
|
|||||||
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
@@ -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]
|
||||||
|
|||||||
+462
-56
@@ -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,16 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ü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 +308,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 +565,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 +573,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 +602,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 +631,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 +835,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 +867,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 +885,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 +930,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 +973,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 +1021,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 +1103,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()
|
||||||
};
|
};
|
||||||
@@ -904,7 +1231,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 +1313,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 +1350,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 +1626,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 +1658,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 +1672,23 @@ 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-5
@@ -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);
|
||||||
|
|||||||
+129
-8
@@ -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,
|
||||||
@@ -196,6 +200,9 @@ impl SanctumFile {
|
|||||||
|
|
||||||
if !is_current {
|
if !is_current {
|
||||||
self.flush_cached_chunk()?;
|
self.flush_cached_chunk()?;
|
||||||
|
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}");
|
||||||
@@ -232,6 +239,9 @@ impl Drop for SanctumFile {
|
|||||||
if let Err(e) = self.flush_cached_chunk() {
|
if let Err(e) = self.flush_cached_chunk() {
|
||||||
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
|
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
|
||||||
}
|
}
|
||||||
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||||
|
data.zeroize();
|
||||||
|
}
|
||||||
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())
|
||||||
@@ -386,6 +396,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 +426,61 @@ 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);
|
||||||
|
|
||||||
|
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 +493,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()
|
||||||
@@ -487,12 +556,25 @@ impl SanctumFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +602,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 +658,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 +667,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 +706,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 +728,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 +767,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);
|
||||||
@@ -693,6 +796,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 +811,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 +829,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 +842,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) {
|
||||||
|
|||||||
+163
@@ -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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user