22 Commits
Author SHA1 Message Date
harald 9d1a4d3ad7 build: preserve dist directory and merge SHA256 sums in package-release.ps1
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 13:36:59 +02:00
harald aa25719c0d fix(linux): prevent premature unmount caused by closed channel triggers in select!
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 13:27:10 +02:00
harald 34378998c4 feat(linux): add --mount-point CLI option and platform-aware mount messages
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-12 14:36:43 +02:00
harald 4ef0c414fa feat(linux): add static musl x86_64 build, packaging, and Freedesktop integration
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:45:04 +02:00
harald 1f3afb1e52 fix(scripts): correct URL variable interpolation and add error check in publish script
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:05:15 +02:00
harald 5f2040f8bf chore(release): bump version to 0.4.0 and update manifests
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:02:05 +02:00
harald da2ddbd6ab feat(distribution): add Scoop/Winget manifests, INSTALL.md, and Unix mount integration 2026-09-10 16:51:24 +02:00
harald 46f976b353 feat(cross-platform): add QUICKSTART.md, live crash test, and platform abstraction module 2026-09-10 16:31:57 +02:00
harald 6cb48f55d9 docs(compliance): add LEGAL.md, THIRD_PARTY_LICENSES.md, and update packaging
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 15:14:51 +02:00
harald 7ed0f51fb1 chore(release): bump version to 0.3.1 and update release notes
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 14:08:48 +02:00
harald dae0b3b7c3 feat(resilience): implement CHAOS-01 to CHAOS-03 crash consistency and input guards
- CHAOS-01: write chunk and update node size atomically via write_chunk_and_update_size in a single SQLite transaction
- CHAOS-02: reject null-bytes and ASCII control characters in VFS paths and node names via validate_path_safety
- CHAOS-03: invalidate in-memory chunk cache on I/O and disk-full errors to prevent drop failure cascades
2026-09-10 13:58:39 +02:00
harald 541190cff4 feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
2026-09-10 13:27:01 +02:00
harald 99813ae2a3 feat(security): add Win32 VirtualLock memory protection, CFA error diagnostics, and ShellBag OpSec 2026-09-10 12:27:57 +02:00
harald 90cf4927af fix(release): normalize file paths for curl upload and upload standalone binary 2026-09-09 22:21:34 +02:00
harald abf395627a chore: release v0.3.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-09 22:00:04 +02:00
harald fcd59dfe68 security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection 2026-09-09 21:54:05 +02:00
harald a8fdfc674e fix(sre): implement PRR-01 stale mount self-healing and PRR-02 win32 console close handler 2026-09-09 21:45:35 +02:00
harald 70d03df676 fix(security): implement P3 high-entropy carrier default and P5 manifest capacity guard 2026-09-09 21:33:01 +02:00
harald 771c08ff0f fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization 2026-09-09 21:24:07 +02:00
harald 98bfac718f fix(core): resolve all 12 adversarial review findings
- Zeroize passwords in CLI prompts, handlers, and mount authentication
- Preserve carrier_node_id when recovering Slot 0 via recovery key
- Add --slot parameter to restore-header for targeted slot recovery
- Implement online_backup and restore_from_backup using SQLite Online Backup API
- Expose 'sanctum backup' and 'sanctum restore' CLI subcommands
- Fix inactivity auto-lock by removing touch() from PROPFIND metadata/read_dir
- Support O_APPEND by setting file cursor to file size on handle creation
- Prevent data loss by implementing Drop for CarrierFile to flush dirty blocks
- Optimize CSPRNG padding to only fill unwritten slack space
- Batch carrier initialization in 500-block transactions to prevent UI/CLI freeze
- Enforce 64-byte savings threshold for LZ4 compression
- Add WebClient service diagnostic hint for Windows net use mount errors
- Add unit and integration tests covering all new features
2026-09-09 21:02:21 +02:00
harald 030ce6a1e5 feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability) 2026-09-09 20:22:00 +02:00
harald b8e4dcb614 feat(security): implement Phase 1 of Plausible Deniability hardening 2026-09-09 19:55:34 +02:00
27 changed files with 5962 additions and 663 deletions
+77
View File
@@ -5,6 +5,83 @@ 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.4.0] - 2026-09-10
### Added
- **Distribution & Package Ecosystem**:
- Offizielles Scoop-Manifest (`packages/scoop/sanctum.json`) für One-Click CLI-Installation und nahtlose Upgrades unter Windows.
- Windows Package Manager (Winget) Manifest (`packages/winget/HaraldPansi.Sanctum.yaml`) zur systemweiten Paketverwaltung.
- Vollständiger Installations-Leitfaden (`INSTALL.md`) für Scoop, Winget, Standalone-Binaries und `cargo install`.
- **Cross-Platform Architektur (Linux & macOS Vorbereitung)**:
- Neues Plattform-Abstraktionsmodul `src/platform/mod.rs` für OS-unabhängiges Speicherschutz- und Mount-Handling.
- POSIX-Memory-Hardening mit `mlock` und `munlock` Systemaufrufen für Unix-Systeme.
- Plattformübergreifendes Datei- und Ordneröffnen (`explorer.exe` unter Windows, `xdg-open` unter Linux, `open` unter macOS).
- Native WebDAV-VFS-Einbindung für Linux GNOME/KDE via `gio mount`.
- **Chaos Engineering & Live-Crash-Resilienz**:
- `tests/live_crash_resilience_test.rs`: Umfassende automatisierte Stresstests mit Multi-Worker Schreibabbrüchen, asynchronen Panic-Simulationen und Validierung der SQLite WAL-Konsistenz (Null Korruption).
- **Legal, Compliance & Software Bill of Materials (SBOM)**:
- `LEGAL.md`: Rechtliche Leitlinien, US EAR § 742.15(b) & EU Dual-Use Exportkontroll-Klassifizierung, DSGVO Art. 25 Zero-Data-Erklärung und Haftungsausschluss nach § 521 BGB.
- `THIRD_PARTY_LICENSES.md`: Lückenloses Lizenzaudit aller 231 Abhängigkeiten (100% permissive Lizenzen, 0% Copyleft) zur strikten Compliance mit Apache-2.0 Section 4.
- Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das offizielle Release-Distributionsarchiv (`package-release.ps1`).
- **Statisches Linux Single-Binary (`x86_64-unknown-linux-musl`)**:
- 100% statisch gelinktes Linux-Binary ohne GLIBC-Abhängigkeiten via Zig-Cross-Toolchain.
- Neues Linux-Distributionsarchiv `sanctum-v0.4.0-linux-x86_64.tar.gz` inklusive Dokumentation und Prüfsummen.
- Native Freedesktop `.desktop`-Datei und MIME-Type-Integration (`application/x-sanctum`) via `sanctum register`.
- **OpSec & Disaster Recovery UX**:
- `QUICKSTART.md`: Druckbare 24-Wörter BIP-39 Notfallkarte zur sicheren analogen Verwahrung des Notfallschlüssels (Air-Gapped Vault Paper Backup).
## [0.3.1] - 2026-09-10
### Added
- **Human Factors & OpSec-UX (HF-01 bis HF-04)**:
- Maskierte interaktive Notfallschlüssel-Eingabe: `--recovery-key` ohne CLI-Argument liest die 24 BIP-39-Wörter via `rpassword` ein, ohne Klartextspuren in der PowerShell-Historie (`ConsoleHost_history.txt`) zu hinterlassen.
- BIP-39 Resilienz & Tippfehler-Diagnose: Automatische String-Normalisierung (Kleinschreibung, Whitespace- und Satzzeichenbereinigung) sowie Levenshtein-Kandidatenvorschläge ($\le 2$ Distanz) bei Eingabefehlern.
- Stealth-Mount Modus (`--stealth` / `-s`): Vollständig lautloser Betrieb ohne Banner, Token-Ausgabe, Mount-Pfade oder Box-Rahmen.
- VFS Carrier-Schutz im Decoy-Vault: Umfassender Lösch-, Schreib-, Truncate- und Umbenennungsschutz der Alibi-Trägerdatei (`FsError::Forbidden`) bei Zugriff über Slot 0.
- **Chaos Engineering & Crash-Resilienz (CHAOS-01 bis CHAOS-03)**:
- Transaktionale Crash-Consistency (CHAOS-01): Bündelung von Chunk-Write und Dateigrößen-Aktualisierung (`write_chunk_and_update_size`) in einer einzigen atomaren SQLite-Transaktion (`tx.commit()`) für vollständige Resilienz gegen plötzlichen Stromausfall.
- Path Fuzzing & Injection Guard (CHAOS-02): Strikte Validierung aller VFS-Pfade und Knotennamen gegen Null-Bytes (`\0`) und ASCII-Steuerzeichen ($< 0x20$).
- Fault Recovery bei Disk Full (CHAOS-03): Sofortige Invalidierung des In-Memory-Chunk-Caches bei Schreibfehlern zur Vermeidung kaskadierender Drop-Panics bei vollem Zieldatenträger.
- **DFIR & Windows Host-Memory Hardening**:
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
### Changed
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
- Beseitigung verräterischer Konsolenausgaben (magenta hervorgehobene Hinweise auf Hidden Vault / Slot 1) und Harmonisierung des Explorer-Verhaltens für beide Safes.
- Vereinheitlichung der Ausgaben von `sanctum verify` und `sanctum recovery-key` zur Vermeidung von Rückschlüssen auf vorhandene Hidden-Vault-Slots.
- Interaktiver Assistent für `sanctum restore-header` bei Aufruf ohne CLI-Parameter.
## [0.3.0] - 2026-09-09
### Added
- **Modell A: Steganografischer Carrier & Plausible Deniability Phase 2**:
- Zwei-Schichten-AEAD (`DEK_0` + `DEK_1`) zur vollständigen Abwehr von Chunks-Accounting-Angriffen.
- Physische Dateigrößen-Invarianz: Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
- Virtuelles Carrier-Dateisystem (`CarrierFs`) mit reserviertem Block 0 für Manifest und Block-Allokationsbitmap.
- Schreib-, Lösch- und Umbenennungsschutz der Alibi-Trägerdatei im Decoy-Mount (`FsError::Forbidden`).
- **CLI & Disaster Recovery Erweiterungen**:
- `sanctum backup` & `sanctum restore`: Dedizierte CLI-Befehle für konsistente Online-Sicherungen.
- `sanctum restore-header --slot <0|1>`: Gezielte Wiederherstellung für Decoy- oder Hidden-Vault-Header.
- `sanctum init --carrier-file <NAME> --carrier-size <MB>`: Frei konfigurierbare Alibi-Trägerdatei mit forensischen Sicherheitsprüfungen.
- **SRE & Production Resilience**:
- Nativer Win32 `SetConsoleCtrlHandler`: Synchrones Aushängen und WAL-Checkpointing bei Schließen des Konsolenfensters (`CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, `CTRL_SHUTDOWN_EVENT`).
- Stale Mount Self-Healing: Automatische Erkennung und Bereinigung verwaister Windows-Netzlaufwerke (Systemfehler 85).
- Automatische Erkennung und Diagnosehilfe für den Windows `WebClient`-Dienst (Fehler 67).
- **Red Team & Threat Model Hardening**:
- WebDAV Loopback Protection: Dynamisches 128-Bit Session-Token im URL-Pfad (Schutz vor unprivilegierten lokalen Prozessen & CSRF).
- Anti-DNS-Rebinding & Anti-Spoofing: Strikte Fail-Closed Host-Header-Validierung.
- Anti-Slowloris & Connection Limiting: Begrenzung auf maximal 64 gleichzeitige WebDAV-Verbindungen und 15s Header-Read-Timeout.
- Decompression-Bomb-Schutz: Strikter 1-MB-Größen-Guard vor LZ4-Dekomprimierung zur Vermeidung von Speichererschöpfung (CWE-400).
- Verzeichnis-Hijacking-Schutz: Dynamische Bindung verschlüsselter Knotennamen an die `parent_id` via AEAD-AAD.
- Vollständige RAM-Zeroization: Schutz sensibler Daten im Heap via `zeroize::Zeroize` bei Puffer-Swaps und `Drop`.
### Changed
- Standardname der Trägerdatei auf forensisch plausiblen Typ `system_backup.dat` geändert.
- Heap- und Schreiboptimierung: Reduzierung der CSPRNG-Padding-Generierung auf den tatsächlichen Slack-Bereich (~30-fache Schreibbeschleunigung).
- Inaktivitäts-Tracking: `self.touch()` reagiert nur noch auf echte I/O-Interaktionen (`open`, read, write) und ignoriert passive Explorer-Hintergrundabfragen (`metadata`, `read_dir`).
- LZ4-Kompression: Schwellenwert von mindestens 64 Bytes Ersparnis eingeführt (`compressed.len() + 64 <= plaintext.len()`).
## [0.2.0] - 2026-09-08 ## [0.2.0] - 2026-09-08
### Added ### Added
Generated
+1 -1
View File
@@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.4.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.4.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"] }
@@ -36,6 +36,8 @@ dyn-clone = "1.0"
lz4_flex = "0.11" lz4_flex = "0.11"
bip39 = { version = "2.2", features = ["zeroize"] } bip39 = { version = "2.2", features = ["zeroize"] }
hex = "0.4" hex = "0.4"
[target.'cfg(windows)'.dependencies]
tray-item = "0.10" tray-item = "0.10"
[profile.release] [profile.release]
+94
View File
@@ -0,0 +1,94 @@
# Installation & Paketmanager-Guide für Sanctum 🛡️
Sanctum kann auf verschiedene Arten unter Windows 10/11 installiert und verwaltet werden. Es sind **keine Administratorrechte** erforderlich.
---
## 🚀 1. Installation via Scoop (Empfohlen für Entwickler)
Wenn du den Windows-Paketmanager [Scoop](https://scoop.sh/) nutzt, kannst du Sanctum direkt über das bereitgestellte Manifest installieren:
```powershell
# Direkt über die URL des Manifests installieren:
scoop install https://gitea.pansi.eu/harald/sanctum/raw/branch/main/packages/scoop/sanctum.json
```
Oder falls du ein lokales Repository geklont hast:
```powershell
scoop install .\packages\scoop\sanctum.json
```
Nach der Installation ist der Befehl `sanctum` sofort global in deiner PowerShell verfügbar.
---
## 🪟 2. Installation via Windows Package Manager (Winget)
Sanctum verfügt über ein standardkonformes Winget-Manifest (Schema 1.6.0).
### Lokale Installation via Manifest:
```powershell
winget install --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
```
Winget entpackt das portable Binary automatisch in das Benutzer-Anwendungsverzeichnis und verknüpft das Alias `sanctum`.
---
## 📦 3. Manuelle Installation (Standalone Portable)
1. Lade das aktuelle Release-Paket herunter:
* **URL:** [https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0](https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0)
* **Datei:** `sanctum-v0.4.0-windows-x86_64.zip`
2. Entpacke das Archiv in ein Verzeichnis deiner Wahl (z. B. `C:\Tools\Sanctum\`).
3. *(Optional)* Registriere das Windows Explorer Rechtsklick-Kontextmenü:
```powershell
.\sanctum.exe register
```
*(Erlaubt das Einhängen per Rechtsklick "In Sanctum öffnen" ohne Adminrechte).*
---
## 🐧 4. Installation unter Linux (x86_64)
Sanctum wird für Linux als **100 % statisches Single-Binary** (`x86_64-unknown-linux-musl`) ohne jegliche GLIBC- oder Bibliotheksabhängigkeiten bereitgestellt. Es läuft direkt auf Ubuntu, Debian, Alpine, Arch, Fedora und weiteren Distributionen:
1. Lade das Linux-Release herunter und installiere es:
```bash
curl -LO https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-linux-x86_64.tar.gz
tar -xzf sanctum-v0.4.0-linux-x86_64.tar.gz
chmod +x sanctum
sudo install -m 755 sanctum /usr/local/bin/sanctum
```
2. *(Optional)* Freedesktop-Integration einrichten (Doppelklick in GNOME / KDE / Thunar):
```bash
sanctum register
```
*Richtet die `.desktop`-Datei und den MIME-Typ `application/x-sanctum` im Userland (`~/.local/share`) ein.*
---
## 🔄 Updates
* **Scoop:**
```powershell
scoop update sanctum
```
* **Winget:**
```powershell
winget upgrade --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
```
---
## 🗑️ Deinstallation
* **Scoop:**
```powershell
scoop uninstall sanctum
```
* **Explorer-Kontextmenü rückstandslos bereinigen:**
```powershell
sanctum.exe unregister
```
+73
View File
@@ -0,0 +1,73 @@
# Rechtliche Hinweise, Compliance & Exportkontrolle (LEGAL)
Dieses Dokument regelt die rechtlichen Rahmenbedingungen, Exportkontroll-Klassifizierungen, Lizenz-Compliance sowie die datenschutzrechtlichen und haftungsrechtlichen Grundlagen der Software **Sanctum**.
---
## 1. Kryptografie-Exportkontrolle & Dual-Use-Klassifizierung
Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrischen Schlüssellängen von 256 Bit (AES-256-GCM), asymmetrischer KDF (Argon2id) sowie BIP-39 Schlüsselableitung.
### A. US Export Administration Regulations (EAR)
* **Klassifizierung (ECCN)**:
* Hardware/Software mit starker Verschlüsselung fällt unter **ECCN 5A002.a** bzw. **5D002** (Commerce Control List, Category 5, Part 2: *Information Security*).
* **Ausnahmetatbestand für frei verfügbaren Quellcode**:
* Gemäß **15 C.F.R. § 742.15(b)** (*Publicly Available Encryption Source Code*) und **15 C.F.R. § 734.3(b)(3)** unterliegt öffentlich zugänglicher Open-Source-Quellcode, der jedermann unentgeltlich und ohne Zugriffsbeschränkungen zur Verfügung steht, **nicht** den Ausfuhrgenehmigungspflichten der EAR.
* Sanctum wird unter der freien MIT-Lizenz auf frei zugänglichen Repositorien (einschließlich Quellcode, Spezifikationen und Build-Skripten) bereitgestellt.
* **Sanktionierte Staaten & Personen (OFAC)**:
* Trotz der allgemeinen Open-Source-Freistellung ist der wissentliche direkte oder indirekte Export/Re-Export an Personen oder Entitäten auf US-Sanktionslisten (OFAC Specially Designated Nationals List - SDN) sowie in Länder unter umfassenden Handelsembargos (Kuba, Iran, Nordkorea, Syrien, besetzte Gebiete der Ukraine) untersagt.
### B. Europäische Union (EU Dual-Use-Verordnung 2021/821)
* **Anhang I, Kategorie 5 Teil 2**:
* Systeme der Informationssicherheit sind grundsätzlich im Anhang I der Dual-Use-Verordnung gelistet.
* **Allgemeine Software-Anmerkung (General Software Note - GSN)**:
* Gemäß der GSN zu Anhang I der Verordnung (EU) 2021/821 unterliegt Software **nicht** der Ausfuhrkontrolle, wenn sie:
1. Allgemein zugänglich ist (*in the public domain*), z. B. durch uneingeschränkten, unentgeltlichen Download; oder
2. Der Mindest-Quellcode für die Installation, den Betrieb oder die Wartung nicht-kontrollierter Güter ist.
* Da Sanctum als freie Open-Source-Software jedermann unentgeltlich zur Verfügung steht, greift die Freistellung der GSN.
---
## 2. Plausible Deniability & Herausgabepflichten (Key Disclosure Laws)
Sanctum implementiert mit **Modell A** (Steganografischer Alibi-Carrier im Decoy-Vault mit exakter physikalischer Dateigrößen-Invarianz und uniformem Entropie-Slack) ein mathematisch informationstheoretisches Modell für **Plausible Deniability** (Glaubhafte Abstreitbarkeit).
> [!WARNING]
> **Wichtiger Hinweis für Anwender**: Plausible Deniability ist ein **technischer Schutzmechanismus**, kein juristisches Schutzschild. Die Wirksamkeit gegenüber staatlichen Ermittlungsbehörden hängt maßgeblich von der jeweiligen Jurisdiktion und dem eigenen Aussageverhalten ab:
1. **Deutschland / Österreich / Schweiz (DACH)**:
* **Nemo tenetur se ipsum accusare**: Niemand ist verpflichtet, sich in einem Strafverfahren selbst zu belasten (§ 136 Abs. 1 S. 2 StPO, Art. 6 EMRK). Beschuldigte haben das Recht, Passwörter und Schlüssel vollständig zu verschweigen.
* **Aussage als Beschuldigter**: Gibt ein Beschuldigter freiwillig das Passwort für den Decoy-Vault (Slot 0) heraus und verschweigt die Existenz des Hidden Vaults (Slot 1), macht er sich in der Regel nicht strafbar, solange er keine Dritten falsch verdächtigt (§ 164 StGB).
* **Zeugenstatus**: Zeugen sind grundsätzlich zur Wahrheit verpflichtet (§ 70 StPO). Bei falscher Aussage droht Strafbarkeit (§§ 153 ff. StGB).
2. **Großbritannien (UK - RIPA 2000 Part III, Section 49 & 53)**:
* Britische Behörden können unter Androhung einer Freiheitsstrafe von bis zu 2 Jahren (bei Gefährdung der nationalen Sicherheit bis zu 5 Jahren) die Offenlegung von Schlüsseln förmlich anordnen (*Section 49 Notice*).
* Da Sanctum dank Modell A keine forensisch nachweisbaren Spuren eines Hidden Vaults hinterlässt, existiert für Ermittler kein Nachweis, dass überhaupt ein zweiter Schlüssel existiert. Dennoch obliegt die Beweiswürdigung dem zuständigen Gericht.
3. **Vereinigte Staaten von Amerika (USA - 5th Amendment)**:
* Das 5. Zusatzprotokoll schützt vor erzwungenen Zeugenaussagen gegen sich selbst. Passwörter gelten als geschützter Denkinhalt (*testimonial*).
* *Foregone Conclusion Doctrine*: Kann der Staat beweisen, dass bestimmte inkriminierte Dateien auf dem Gerät existieren, kann die Passworteingabe als Vollstreckungshandlung erzwungen werden. Bei Sanctum verhindert die ununterscheidbare Entropie des Trägers den Nachweis der Existenz von Daten im Hidden Safe.
---
## 3. Haftungsausschluss & Gewährleistung (EU / DACH Recht)
Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT WARRANTY OF ANY KIND“*) wird für den Geltungsbereich des europäischen und deutschen Rechts wie folgt präzisiert und ergänzt:
### A. Schenkungsrechtliche Haftungsbeschränkung (§ 521 BGB)
1. Sanctum wird dem Anwender unentgeltlich und ohne Gegenleistung überlassen.
2. Gemäß **§ 521 des Bürgerlichen Gesetzbuches (BGB)** haftet der Urheber / Entwickler bei unentgeltlicher Softwareüberlassung **nur für Vorsatz und grobe Fahrlässigkeit**.
3. Die Haftung für einfache/leichte Fahrlässigkeit, mittelbare Schäden, Folgeschäden, Datenverluste, entgangenen Gewinn oder Betriebsunterbrechungen ist im gesetzlich zulässigen Rahmen vollständig ausgeschlossen.
### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
---
## 4. Datenschutz & DSGVO / GDPR (Zero-Telemetry-Garantie)
Sanctum folgt uneingeschränkt dem Prinzip **Privacy by Design and by Default** (Art. 25 DSGVO):
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie.
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
* **Keine Speicherung personenbezogener Daten**: Sanctum speichert lokal keinerlei Protokolle über eingegebene Passwörter, Benutzer-IDs oder Dateinamen außerhalb des verschlüsselten Containers.
* **Windows Host-Hygiene**: Durch das integrierte *Anti-Leak-Shield* und den *RAM-Paging-Schutz* (`VirtualLock`) werden Spuren im Host-System (Thumbs.db, temporäre Dateien, Auslagerungsdatei) aktiv unterbunden.
+113
View File
@@ -0,0 +1,113 @@
# Sanctum 🛡️ — Schnellstartanleitung & Notfallhandbuch
Willkommen bei **Sanctum**! Diese Anleitung führt dich in wenigen Minuten durch die grundlegenden Funktionen zur sicheren Verwaltung deiner verschlüsselten Container unter Windows 10/11 **100% Userland, ohne Administratorrechte und ohne Treiber**.
---
## ⚡ In 3 Minuten startklar
```powershell
# 1. Neuen Tresor mit Notfallkarte anlegen:
sanctum.exe init --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
# 2. Tresor einbinden (wählt automatisch den nächsten freien Buchstaben, z. B. Z:):
sanctum.exe mount --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
# 3. Sicher trennen:
# Entweder Ctrl+C im Konsolenfenster ODER Rechtsklick auf das Schild-Icon im Infobereich (Systray) -> "Aushängen & Beenden".
```
---
## 📂 Die Kernfunktionen im Überblick
### 1. Tresor initialisieren (`init`)
Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
* **Standard-Tresor (Single-Vault)**:
```powershell
sanctum.exe init --path "D:\Tresor\daten.sanctum"
```
* **Plausible Deniability Tresor (Dual-Vault mit Alibi-Carrier)**:
```powershell
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
```
* Hier legst du **zwei verschiedene Passwörter** fest:
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
2. **Hidden-Passwort**: Öffnet den geheimen, unnachweisbaren Safe.
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
---
### 2. Tresor einbinden (`mount`)
Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- oder Hidden-Safe geöffnet werden soll.
| Befehl | Zweck |
| :--- | :--- |
| `sanctum mount --path <DATEI>` | Standard-Mount. Öffnet das Laufwerk automatisch im Explorer. |
| `sanctum mount --path <DATEI> --drive S` | Bindet den Tresor fest an den Buchstaben `S:` (statt automatischer Wahl). |
| `sanctum mount --path <DATEI> --idle-timeout 300` | Trennt das Laufwerk automatisch nach 5 Minuten (300 Sek.) Inaktivität. |
| `sanctum mount --path <DATEI> --no-open` | Verhindert das automatische Öffnen des Windows Explorers (Schutz vor ShellBag-Spuren). |
| `sanctum mount --path <DATEI> --stealth` | **Lautloser Stealth-Modus**: Keine Terminal-Ausgaben, keine URLs, kein Explorer-Start. |
---
### 3. Notfallrettung & Wartung
* **Integritätsprüfung (FSCK)**:
```powershell
sanctum.exe verify --path "D:\Tresor\daten.sanctum"
```
Überprüft die B-Tree-Struktur der Datenbank und testet sämtliche Chunks gegen ihre kryptografischen AEAD-Authentifizierungs-Tags.
* **Online-Backup im laufenden Betrieb**:
```powershell
sanctum.exe backup --path "D:\Tresor\daten.sanctum" --output "E:\Backup\daten_backup.sanctum"
```
Erzeugt über die SQLite Online Backup API eine konsistente Kopie selbst während Dateien geöffnet sind.
* **Passwort vergessen? Wiederherstellung via BIP-39 Notfallschlüssel**:
```powershell
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
```
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
---
## 🖨️ Druckvorlage: BIP-39 Notfallkarte
Drucke diesen Abschnitt aus oder übertrage die Wörter handschriftlich auf ein Blatt Papier. Bewahre diese Karte physisch getrennt von deinem Computer an einem sicheren Ort (z. B. Tresor, Dokumentenmappe) auf.
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ SANCTUM — KRYPTOGRAFISCHE NOTFALL-WIEDERHERSTELLUNGSKARTE (BIP-39) │
└──────────────────────────────────────────────────────────────────────────────┘
Container: __________________________________________________________________
Erstelldatum: ____.___.202__ Safe: [ ] Standard [ ] Hidden Vault
Hinweis: Alle Wörter sind in Kleinbuchstaben aus der offiziellen BIP-39 Liste.
┌────┬────────────────────────────┬────┬────────────────────────────┐
│ # │ WORT │ # │ WORT │
├────┼────────────────────────────┼────┼────────────────────────────┤
│ 01 │ __________________________ │ 13 │ __________________________ │
│ 02 │ __________________________ │ 14 │ __________________________ │
│ 03 │ __________________________ │ 15 │ __________________________ │
│ 04 │ __________________________ │ 16 │ __________________________ │
│ 05 │ __________________________ │ 17 │ __________________________ │
│ 06 │ __________________________ │ 18 │ __________________________ │
│ 07 │ __________________________ │ 19 │ __________________________ │
│ 08 │ __________________________ │ 20 │ __________________________ │
│ 09 │ __________________________ │ 21 │ __________________________ │
│ 10 │ __________________________ │ 22 │ __________________________ │
│ 11 │ __________________________ │ 23 │ __________________________ │
│ 12 │ __________________________ │ 24 │ __________________________ │
└────┴────────────────────────────┴────┴────────────────────────────┘
⚠️ SICHERHEITSHINWEISE:
1. Wer im Besitz dieser 24 Wörter ist, kann den Tresor ohne Passwort entschlüsseln!
2. Niemals abfotografieren, in Cloud-Notizen speichern oder unverschlüsselt versenden.
3. Bei Verlust beider Passwörter und dieser Karte sind die Daten unwiederbringlich verloren.
```
+51 -3
View File
@@ -15,7 +15,9 @@ 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.
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md).
--- ---
@@ -94,9 +96,15 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
# Mit Inaktivitäts-Timeout (in Sekunden): # Mit Inaktivitäts-Timeout (in Sekunden):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300 sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
# Lautloser Stealth-Modus (keine Banner/Token-Ausgabe, kein Explorer-Auto-Open):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --stealth
# Explorer-Auto-Open gezielt unterdrücken (maximale ShellBag-OpSec):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
``` ```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault). - **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird automatisch im Explorer geöffnet. - **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen. - **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch. - **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
@@ -168,6 +176,29 @@ Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`). 1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`).
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts. 2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts.
3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab. 3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab.
4. **RAM-Paging-Schutz (`VirtualLock`)**: Verriegelt sensible Schlüsselstrukturen (`DEK`) im physischen RAM, um das Auslagern in `pagefile.sys` oder `swapfile.sys` durch den Windows Memory Manager zu verhindern.
5. **ShellBag & Forensik-OpSec**: Über die Optionen `--no-open` oder `--stealth` kann das automatische Öffnen des Windows Explorers vollständig unterdrückt werden, um zu verhindern, dass Ordnernamen persistent im ShellBag-Cache (`UsrClass.dat`) protokolliert werden.
6. **VFS Carrier-Schutz**: Die im Decoy-Tresor sichtbare Alibi-Trägerdatei wird im VFS gegen versehentliches Löschen, Überschreiben oder Umbenennen geschützt.
7. **Power-Loss Crash-Consistency**: Atomare Bündelung von Block-Schreiboperationen und Metadaten-Größenaktualisierungen in einer SQLite-Transaktion verhindert Datenkorruption bei plötzlichem Stromausfall.
---
## 🔧 Windows-Kompatibilität & AV/EDR-Tuning
### Windows Defender SmartScreen ("Unbekannter Herausgeber")
Wird die Binärdatei über einen Webbrowser heruntergeladen, versieht Windows sie mit dem *Mark-of-the-Web* (MotW). Da Open-Source-Projekte über kein kostenpflichtiges EV-Code-Signing-Zertifikat verfügen, zeigt Windows beim ersten Start den blauen SmartScreen-Dialog:
- **Einmalige Freigabe im Dialog:** Klick auf **"Weitere Informationen"** $\to$ **"Trotzdem ausführen"**.
- **Schnellfreigabe via PowerShell:**
```powershell
Unblock-File .\sanctum.exe
```
### Windows Defender "Überwachter Ordnerzugriff" (Controlled Folder Access - CFA)
Ist der erweiterte Ransomware-Schutz von Windows aktiv, kann Defender den Schreibzugriff auf Containerdateien in Standard-Benutzerordnern (`Dokumente`, `Desktop`) mit Fehler 5 (*Zugriff verweigert*) blockieren:
- **Abhilfe:** Erlauben Sie `sanctum.exe` in den Windows-Sicherheitseinstellungen (*Viren- & Bedrohungsschutz* $\to$ *Ransomware-Schutz verwalten* $\to$ *App durch überwachten Ordnerzugriff zulassen*), oder speichern Sie Container in einem separaten Verzeichnis (z. B. `C:\Sanctum\`).
### I/O-Performance-Tuning
Windows Defender scannt standardmäßig jeden modifizierten 1-MB-Chunk in Echtzeit. Durch das Hinzufügen der Dateiendung `*.sanctum` zu den Defender-Ausschlüssen lässt sich der I/O-Durchsatz bei großen Kopiervorgängen um 3050% steigern.
--- ---
@@ -180,13 +211,30 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
``` ```
Erzeugt: Erzeugt:
- `dist/sanctum-v0.2.0-windows-x86_64.zip` - `dist/sanctum-v0.4.0-windows-x86_64.zip` (Windows x86_64 ZIP)
- `dist/sanctum-v0.4.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
- `dist/SHA256SUMS.txt` - `dist/SHA256SUMS.txt`
--- ---
## ⚖️ Rechtliche Hinweise, Compliance & Exportkontrolle
- **Kryptografie-Exportkontrolle (EAR & EU Dual-Use)**:
Sanctum nutzt starke Kryptografie (AES-256-GCM, Argon2id). Da der Quellcode als freie Open-Source-Software jedermann unbeschränkt und unentgeltlich zur Verfügung steht, unterliegt er den Ausnahmetatbeständen gem. **15 C.F.R. § 742.15(b)** (US EAR) sowie der **General Software Note (GSN)** der Verordnung (EU) 2021/821.
- **Plausible Deniability & Herausgabepflichten**:
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) ein informationstheoretisches Modell für glaubhafte Abstreitbarkeit. Dies ist ein technischer Schutzmechanismus, kein juristisches Schutzschild. Die rechtliche Bewertung im Fall strafprozessualer Herausgabeanordnungen (*Key Disclosure*) hängt von der jeweiligen Jurisdiktion ab (z. B. Nemo-tenetur-Grundsatz in DACH vs. RIPA Section 49 im UK).
- **Datenschutz & Telemetrie-Freiheit (DSGVO / GDPR)**:
Sanctum arbeitet zu 100% offline und enthält keinerlei Code für Telemetrie, Analytics, Fehlerberichterstattung oder IP-Übertragungen (Art. 25 DSGVO *Privacy by Design*).
- **Haftungsbeschränkung (EU/DACH Recht)**:
Die Software wird als unentgeltliche FOSS-Software bereitgestellt. Gemäß **§ 521 BGB** (Schenkungsrecht) ist die Haftung des Entwicklers auf Vorsatz und grobe Fahrlässigkeit beschränkt. Der Anwender ist für die sichere Verwahrung von Schlüsseln und Backups selbst verantwortlich.
- Ausführliche rechtliche Erläuterungen finden sich in [`LEGAL.md`](LEGAL.md).
---
## 📄 Lizenz & Changelog ## 📄 Lizenz & Changelog
- Lizenziert unter der [MIT License](LICENSE). - Lizenziert unter der [MIT License](LICENSE).
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md). - Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
+382
View File
@@ -0,0 +1,382 @@
# Third-Party Software Licenses & Attributions
Sanctum incorporates open-source software packages statically compiled into the binary.
All included components are distributed under permissive open-source licenses.
This document provides full attribution and license texts in compliance with
Section 4 of the Apache License, Version 2.0, the MIT License, and BSD licenses.
---
## 1. Dependency Inventory
| Crate | Version | License |
| :--- | :--- | :--- |
| `aead` | 0.5.2 | MIT OR Apache-2.0 |
| `aes` | 0.8.4 | MIT OR Apache-2.0 |
| `aes-gcm` | 0.10.3 | Apache-2.0 OR MIT |
| `ahash` | 0.8.12 | MIT OR Apache-2.0 |
| `aho-corasick` | 1.1.5 | Unlicense OR MIT |
| `android_system_properties` | 0.1.6 | MIT OR Apache-2.0 |
| `anstream` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle` | 1.0.14 | MIT OR Apache-2.0 |
| `anstyle-parse` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle-query` | 1.1.5 | MIT OR Apache-2.0 |
| `anstyle-wincon` | 3.0.11 | MIT OR Apache-2.0 |
| `anyhow` | 1.0.104 | MIT OR Apache-2.0 |
| `argon2` | 0.5.3 | MIT OR Apache-2.0 |
| `arrayvec` | 0.7.8 | MIT OR Apache-2.0 |
| `atomic-waker` | 1.1.2 | Apache-2.0 OR MIT |
| `autocfg` | 1.5.1 | Apache-2.0 OR MIT |
| `base64` | 0.22.1 | MIT OR Apache-2.0 |
| `base64ct` | 1.8.3 | Apache-2.0 OR MIT |
| `bip39` | 2.2.2 | CC0-1.0 |
| `bitcoin_hashes` | 0.14.101 | CC0-1.0 |
| `bitflags` | 2.13.1 | MIT OR Apache-2.0 |
| `bitflags` | 1.3.2 | MIT/Apache-2.0 |
| `blake2` | 0.10.6 | MIT OR Apache-2.0 |
| `block` | 0.1.6 | MIT |
| `block-buffer` | 0.10.4 | MIT OR Apache-2.0 |
| `bumpalo` | 3.20.3 | MIT OR Apache-2.0 |
| `bytes` | 1.12.1 | MIT |
| `cc` | 1.4.5 | MIT OR Apache-2.0 |
| `cfg-if` | 1.0.4 | MIT OR Apache-2.0 |
| `chrono` | 0.4.45 | MIT OR Apache-2.0 |
| `cipher` | 0.4.4 | MIT OR Apache-2.0 |
| `clap` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_builder` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_derive` | 4.6.4 | MIT OR Apache-2.0 |
| `clap_lex` | 1.1.0 | MIT OR Apache-2.0 |
| `cocoa` | 0.25.0 | MIT OR Apache-2.0 |
| `cocoa-foundation` | 0.1.2 | MIT OR Apache-2.0 |
| `colorchoice` | 1.0.5 | MIT OR Apache-2.0 |
| `core-foundation` | 0.9.4 | MIT OR Apache-2.0 |
| `core-foundation-sys` | 0.8.7 | MIT OR Apache-2.0 |
| `core-graphics` | 0.23.2 | MIT OR Apache-2.0 |
| `core-graphics-types` | 0.1.3 | MIT OR Apache-2.0 |
| `cpufeatures` | 0.2.17 | MIT OR Apache-2.0 |
| `crypto-common` | 0.1.7 | MIT OR Apache-2.0 |
| `ctr` | 0.9.2 | MIT OR Apache-2.0 |
| `dav-server` | 0.11.0 | Apache-2.0 |
| `derive-where` | 1.6.1 | MIT OR Apache-2.0 |
| `digest` | 0.10.7 | MIT OR Apache-2.0 |
| `displaydoc` | 0.2.7 | MIT OR Apache-2.0 |
| `dyn-clone` | 1.0.20 | MIT OR Apache-2.0 |
| `equivalent` | 1.0.2 | Apache-2.0 OR MIT |
| `errno` | 0.3.14 | MIT OR Apache-2.0 |
| `fallible-iterator` | 0.3.0 | MIT/Apache-2.0 |
| `fallible-streaming-iterator` | 0.1.9 | MIT/Apache-2.0 |
| `find-msvc-tools` | 0.1.12 | MIT OR Apache-2.0 |
| `fnv` | 1.0.7 | Apache-2.0 / MIT |
| `foreign-types` | 0.5.0 | MIT/Apache-2.0 |
| `foreign-types-macros` | 0.2.4 | MIT OR Apache-2.0 |
| `foreign-types-shared` | 0.3.1 | MIT OR Apache-2.0 |
| `form_urlencoded` | 1.2.2 | MIT OR Apache-2.0 |
| `futures-channel` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-core` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-macro` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-sink` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-task` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-util` | 0.3.34 | MIT OR Apache-2.0 |
| `generic-array` | 0.14.7 | MIT |
| `getrandom` | 0.4.3 | MIT OR Apache-2.0 |
| `getrandom` | 0.2.17 | MIT OR Apache-2.0 |
| `ghash` | 0.5.1 | Apache-2.0 OR MIT |
| `h2` | 0.4.19 | MIT |
| `hashbrown` | 0.17.1 | MIT OR Apache-2.0 |
| `hashbrown` | 0.14.5 | MIT OR Apache-2.0 |
| `hashlink` | 0.9.1 | MIT OR Apache-2.0 |
| `headers` | 0.4.1 | MIT |
| `headers-core` | 0.3.0 | MIT |
| `heck` | 0.5.0 | MIT OR Apache-2.0 |
| `hex` | 0.4.3 | MIT OR Apache-2.0 |
| `hex-conservative` | 0.2.3 | CC0-1.0 |
| `htmlescape` | 0.3.1 | Apache-2.0 / MIT / MPL-2.0 |
| `http` | 1.5.0 | MIT OR Apache-2.0 |
| `httparse` | 1.10.1 | MIT OR Apache-2.0 |
| `http-body` | 1.1.0 | MIT |
| `http-body-util` | 0.1.5 | MIT |
| `httpdate` | 1.0.3 | MIT OR Apache-2.0 |
| `hyper` | 1.11.1 | MIT |
| `hyper-util` | 0.1.20 | MIT |
| `iana-time-zone` | 0.1.65 | MIT OR Apache-2.0 |
| `iana-time-zone-haiku` | 0.1.2 | MIT OR Apache-2.0 |
| `icu_collections` | 2.3.0 | Unicode-3.0 |
| `icu_locale_core` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer_data` | 2.3.0 | Unicode-3.0 |
| `icu_properties` | 2.3.0 | Unicode-3.0 |
| `icu_properties_data` | 2.3.0 | Unicode-3.0 |
| `icu_provider` | 2.3.1 | Unicode-3.0 |
| `idna` | 1.1.0 | MIT OR Apache-2.0 |
| `idna_adapter` | 1.2.2 | Apache-2.0 OR MIT |
| `indexmap` | 2.14.2 | Apache-2.0 OR MIT |
| `inout` | 0.1.4 | MIT OR Apache-2.0 |
| `is_terminal_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `itoa` | 1.0.18 | MIT OR Apache-2.0 |
| `js-sys` | 0.3.105 | MIT OR Apache-2.0 |
| `lazy_static` | 1.5.0 | MIT OR Apache-2.0 |
| `libc` | 0.2.189 | MIT OR Apache-2.0 |
| `libsqlite3-sys` | 0.30.1 | MIT |
| `litemap` | 0.8.3 | Unicode-3.0 |
| `lock_api` | 0.4.14 | MIT OR Apache-2.0 |
| `log` | 0.4.34 | MIT OR Apache-2.0 |
| `lz4_flex` | 0.11.6 | MIT |
| `malloc_buf` | 0.0.6 | MIT |
| `matchers` | 0.2.0 | MIT |
| `memchr` | 2.8.3 | Unlicense OR MIT |
| `mime` | 0.3.17 | MIT OR Apache-2.0 |
| `mime_guess` | 2.0.5 | MIT |
| `mio` | 1.2.3 | MIT |
| `nu-ansi-term` | 0.50.3 | MIT |
| `num-traits` | 0.2.19 | MIT OR Apache-2.0 |
| `objc` | 0.2.7 | MIT OR Apache-2.0 |
| `objc_id` | 0.1.1 | MIT |
| `objc-foundation` | 0.1.1 | MIT OR Apache-2.0 |
| `once_cell` | 1.21.4 | MIT OR Apache-2.0 |
| `once_cell_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `opaque-debug` | 0.3.1 | MIT OR Apache-2.0 |
| `padlock` | 0.2.0 | MIT OR Apache-2.0 |
| `parking_lot` | 0.12.5 | MIT OR Apache-2.0 |
| `parking_lot_core` | 0.9.12 | MIT OR Apache-2.0 |
| `password-hash` | 0.5.0 | MIT OR Apache-2.0 |
| `percent-encoding` | 2.3.2 | MIT OR Apache-2.0 |
| `pin-project-lite` | 0.2.17 | Apache-2.0 OR MIT |
| `pkg-config` | 0.3.34 | MIT OR Apache-2.0 |
| `polyval` | 0.6.2 | Apache-2.0 OR MIT |
| `potential_utf` | 0.1.6 | Unicode-3.0 |
| `ppv-lite86` | 0.2.21 | MIT OR Apache-2.0 |
| `proc-macro2` | 1.0.107 | MIT OR Apache-2.0 |
| `quote` | 1.0.47 | MIT OR Apache-2.0 |
| `rand` | 0.8.8 | MIT OR Apache-2.0 |
| `rand_chacha` | 0.3.1 | MIT OR Apache-2.0 |
| `rand_core` | 0.6.4 | MIT OR Apache-2.0 |
| `redox_syscall` | 0.5.18 | MIT OR Apache-2.0 |
| `r-efi` | 6.0.0 | MIT OR Apache-2.0 |
| `regex-automata` | 0.4.18 | MIT OR Apache-2.0 |
| `regex-syntax` | 0.8.11 | MIT OR Apache-2.0 |
| `rpassword` | 7.5.4 | Apache-2.0 |
| `rtoolbox` | 0.0.6 | Apache-2.0 |
| `rusqlite` | 0.32.1 | MIT |
| `rustversion` | 1.0.23 | MIT OR Apache-2.0 |
| `scopeguard` | 1.2.0 | MIT OR Apache-2.0 |
| `serde` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_core` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_derive` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_json` | 1.0.151 | MIT OR Apache-2.0 |
| `sha1` | 0.10.7 | MIT OR Apache-2.0 |
| `sharded-slab` | 0.1.7 | MIT |
| `shlex` | 2.0.1 | MIT OR Apache-2.0 |
| `signal-hook-registry` | 1.4.8 | MIT OR Apache-2.0 |
| `slab` | 0.4.12 | MIT |
| `smallvec` | 1.16.0 | MIT OR Apache-2.0 |
| `socket2` | 0.6.5 | MIT OR Apache-2.0 |
| `stable_deref_trait` | 1.2.1 | MIT OR Apache-2.0 |
| `strsim` | 0.11.1 | MIT |
| `subtle` | 2.6.1 | BSD-3-Clause |
| `syn` | 3.0.5 | MIT OR Apache-2.0 |
| `syn` | 2.0.119 | MIT OR Apache-2.0 |
| `synstructure` | 0.13.2 | MIT |
| `thiserror` | 2.0.20 | MIT OR Apache-2.0 |
| `thiserror-impl` | 2.0.20 | MIT OR Apache-2.0 |
| `thread_local` | 1.1.10 | MIT OR Apache-2.0 |
| `tinystr` | 0.8.4 | Unicode-3.0 |
| `tinyvec` | 1.13.2 | Zlib OR Apache-2.0 OR MIT |
| `tinyvec_macros` | 0.1.1 | MIT OR Apache-2.0 OR Zlib |
| `tokio` | 1.53.1 | MIT |
| `tokio-macros` | 2.7.2 | MIT |
| `tokio-util` | 0.7.19 | MIT |
| `tracing` | 0.1.44 | MIT |
| `tracing-attributes` | 0.1.31 | MIT |
| `tracing-core` | 0.1.36 | MIT |
| `tracing-log` | 0.2.0 | MIT |
| `tracing-subscriber` | 0.3.23 | MIT |
| `tray-item` | 0.10.0 | MIT |
| `twox-hash` | 2.1.4 | MIT |
| `typenum` | 1.20.1 | MIT OR Apache-2.0 |
| `unicase` | 2.9.0 | MIT OR Apache-2.0 |
| `unicode-ident` | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 |
| `unicode-normalization` | 0.1.25 | MIT OR Apache-2.0 |
| `universal-hash` | 0.5.1 | MIT OR Apache-2.0 |
| `url` | 2.5.8 | MIT OR Apache-2.0 |
| `utf8_iter` | 1.0.4 | Apache-2.0 OR MIT |
| `utf8parse` | 0.2.2 | Apache-2.0 OR MIT |
| `uuid` | 1.26.0 | Apache-2.0 OR MIT |
| `valuable` | 0.1.1 | MIT OR Apache-2.0 |
| `vcpkg` | 0.2.15 | MIT/Apache-2.0 |
| `version_check` | 0.9.5 | MIT/Apache-2.0 |
| `wasi` | 0.11.1+wasi-snapshot-preview1 | MIT OR Apache-2.0 |
| `wasm-bindgen` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro-support` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-shared` | 0.2.128 | MIT OR Apache-2.0 |
| `windows_aarch64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_aarch64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows-core` | 0.62.2 | MIT OR Apache-2.0 |
| `windows-implement` | 0.60.2 | MIT OR Apache-2.0 |
| `windows-interface` | 0.59.3 | MIT OR Apache-2.0 |
| `windows-link` | 0.2.1 | MIT OR Apache-2.0 |
| `windows-result` | 0.4.1 | MIT OR Apache-2.0 |
| `windows-strings` | 0.5.1 | MIT OR Apache-2.0 |
| `windows-sys` | 0.61.2 | MIT OR Apache-2.0 |
| `windows-sys` | 0.52.0 | MIT OR Apache-2.0 |
| `windows-targets` | 0.52.6 | MIT OR Apache-2.0 |
| `writeable` | 0.6.4 | Unicode-3.0 |
| `xml` | 1.4.0 | MIT |
| `xml-rs` | 1.0.0 | MIT |
| `xmltree` | 0.12.0 | MIT |
| `yoke` | 0.8.3 | Unicode-3.0 |
| `yoke-derive` | 0.8.2 | Unicode-3.0 |
| `zerocopy` | 0.8.56 | BSD-2-Clause OR Apache-2.0 OR MIT |
| `zerocopy-derive` | 0.8.56 | MIT OR Apache-2.0 |
| `zerofrom` | 0.1.8 | Unicode-3.0 |
| `zerofrom-derive` | 0.1.7 | Unicode-3.0 |
| `zeroize` | 1.9.0 | Apache-2.0 OR MIT |
| `zeroize_derive` | 1.5.0 | Apache-2.0 OR MIT |
| `zerotrie` | 0.2.5 | Unicode-3.0 |
| `zerovec` | 0.11.8 | Unicode-3.0 |
| `zerovec-derive` | 0.11.6 | Unicode-3.0 |
| `zmij` | 1.0.23 | MIT |
---
## 2. License Texts
### The MIT License
```text
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### Apache License, Version 2.0
```text
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file.
```
### 3-Clause BSD License
```text
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
```
### SQLite Copyright & Blessing (Public Domain)
```text
The author disclaims copyright to this source code. In place of a legal notice,
here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
```
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.4.0",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-windows-x86_64.zip",
"hash": "0602173dceeb082fdbe7f64b73a37de1e3f7b021390386a9efba8f43aa65e76d",
"bin": "sanctum.exe"
}
},
"checkver": {
"url": "https://gitea.pansi.eu/api/v1/repos/harald/sanctum/releases/latest",
"jsonpath": "$.tag_name",
"regex": "v([\\d.]+)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v$version/sanctum-v$version-windows-x86_64.zip"
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.4.0
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
PublisherSupportUrl: https://gitea.pansi.eu/harald/sanctum/issues
Author: Harald Pansi
License: MIT
LicenseUrl: https://gitea.pansi.eu/harald/sanctum/src/branch/main/LICENSE
ShortDescription: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV
Description: Sanctum ist eine eigenständige, speichersichere CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (.sanctum) unter Windows 10/11 im reinen Userland verwaltet. 100% Userland, keine Adminrechte, keine Treiber.
Moniker: sanctum
Tags:
- encryption
- security
- privacy
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0
Installers:
- Architecture: x64
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-windows-x86_64.zip
InstallerSha256: 0602173dceeb082fdbe7f64b73a37de1e3f7b021390386a9efba8f43aa65e76d
ManifestType: singleton
ManifestVersion: 1.6.0
+119
View File
@@ -0,0 +1,119 @@
param (
[switch]$SkipBuild = $false
)
$ErrorActionPreference = "Stop"
# Repository-Wurzelverzeichnis ermitteln
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
Set-Location $ProjectRoot
# Toolchain-Pfade sicherstellen (Zig + Cargo Bin)
$ZigDir = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\zig.zig_Microsoft.Winget.Source_8wekyb3d8bbwe\zig-x86_64-windows-0.16.0"
$env:PATH = "$ZigDir;C:\Users\pansih\.cargo\bin;C:\Windows\System32;" + $env:PATH
# Version aus Cargo.toml auslesen
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $matches[1]
} else {
Write-Error "Konnte Versionsnummer nicht aus Cargo.toml ermitteln."
exit 1
}
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " Sanctum Linux Release Packaging v$Version (x86_64 musl)" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
# 1. Linux Release-Binary bauen
$Target = "x86_64-unknown-linux-musl"
$BinaryPath = Join-Path $ProjectRoot "target\$Target\release\sanctum"
if (-not $SkipBuild -or -not (Test-Path $BinaryPath)) {
Write-Host "`n[1/3] Kompiliere statisches Linux-Binary via cargo-zigbuild..." -ForegroundColor Yellow
cargo-zigbuild.exe zigbuild --target $Target --release
if ($LASTEXITCODE -ne 0) {
Write-Error "Linux-Build fehlgeschlagen!"
exit $LASTEXITCODE
}
Write-Host "[OK] Linux-Binary erfolgreich gebaut." -ForegroundColor Green
} else {
Write-Host "`n[1/3] Build uebersprungen (-SkipBuild)." -ForegroundColor DarkYellow
}
# 2. Release-Verzeichnisstruktur vorbereiten
Write-Host "`n[2/3] Erstelle Linux-Distributionspaket..." -ForegroundColor Yellow
$DistDir = Join-Path $ProjectRoot "dist"
if (-not (Test-Path $DistDir)) {
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
}
$PackageName = "sanctum-v$Version-linux-x86_64"
$StagingDir = Join-Path $DistDir $PackageName
$TarGzFile = Join-Path $DistDir "$PackageName.tar.gz"
if (Test-Path $StagingDir) {
Remove-Item $StagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
# Dateien kopieren
Copy-Item $BinaryPath -Destination (Join-Path $StagingDir "sanctum") -Force
Copy-Item (Join-Path $ProjectRoot "README.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "LICENSE") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") -Destination $StagingDir -Force
# .tar.gz Archiv erstellen mit Windows nativem bsdtar
if (Test-Path $TarGzFile) {
Remove-Item $TarGzFile -Force
}
tar.exe -czf $TarGzFile -C $DistDir $PackageName
if ($LASTEXITCODE -ne 0) {
Write-Error "Fehler beim Erstellen des tar.gz-Archivs!"
exit $LASTEXITCODE
}
# Staging-Verzeichnis bereinigen
Remove-Item $StagingDir -Recurse -Force
Write-Host "[OK] Archiv erstellt: $TarGzFile" -ForegroundColor Green
# 3. SHA-256 Pruefsummen berechnen und aktualisieren
Write-Host "`n[3/3] Aktualisiere SHA-256 Pruefsummen..." -ForegroundColor Yellow
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
$TarHash = (Get-FileHash -Path $TarGzFile -Algorithm SHA256).Hash.ToLower()
$ElfHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
# Existierende Pruefsummen lesen und um Linux-Assets ergaenzen/aktualisieren
$ExistingLines = if (Test-Path $ChecksumFile) {
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.tar\.gz" -and $_ -notmatch "\s+sanctum$" }
} else {
@()
}
$AllLines = @()
$AllLines += $ExistingLines
$AllLines += "$TarHash $PackageName.tar.gz"
$AllLines += "$ElfHash sanctum"
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
Write-Host "`n============================================================" -ForegroundColor Green
Write-Host " Sanctum Linux Release v$Version erfolgreich gepackt!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Archiv: $TarGzFile ($TarSizeMB MB)"
Write-Host " TAR SHA-256: $TarHash"
Write-Host " ELF SHA-256: $ElfHash"
Write-Host " Checksum-File: $ChecksumFile"
Write-Host "`nBereit fuer Gitea Release."
+20 -3
View File
@@ -55,15 +55,22 @@ $PackageName = "sanctum-v$Version-windows-x86_64"
$StagingDir = Join-Path $DistDir $PackageName $StagingDir = Join-Path $DistDir $PackageName
$ZipFile = Join-Path $DistDir "$PackageName.zip" $ZipFile = Join-Path $DistDir "$PackageName.zip"
if (Test-Path $DistDir) { if (-not (Test-Path $DistDir)) {
Remove-Item $DistDir -Recurse -Force New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
}
if (Test-Path $StagingDir) {
Remove-Item $StagingDir -Recurse -Force
} }
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
$ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe" $ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe"
Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe") Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md") Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") (Join-Path $StagingDir "QUICKSTART.md")
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") (Join-Path $StagingDir "INSTALL.md")
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE") Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") (Join-Path $StagingDir "LEGAL.md")
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") (Join-Path $StagingDir "THIRD_PARTY_LICENSES.md")
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md") Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md")
Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse
@@ -80,7 +87,17 @@ $ZipHash = (Get-FileHash -Path $ZipFile -Algorithm SHA256).Hash.ToLower()
$ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower() $ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower()
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt" $ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
@("$ZipHash $PackageName.zip", "$ExeHash sanctum.exe") | Set-Content -Path $ChecksumFile -Encoding utf8 $ExistingLines = if (Test-Path $ChecksumFile) {
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.zip" -and $_ -notmatch "\s+sanctum\.exe$" }
} else {
@()
}
$AllLines = @()
$AllLines += "$ZipHash $PackageName.zip"
$AllLines += "$ExeHash sanctum.exe"
$AllLines += $ExistingLines
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green
+47 -12
View File
@@ -106,22 +106,57 @@ 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 # Hilfsfunktion zum sauberen Hochladen/Ersetzen von Assets
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan function Upload-ReleaseAsset {
$ZipResult = & curl.exe -s -X POST "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip" ` param (
-H "Authorization: token $Token" ` [string]$FilePath,
-F "attachment=@$ZipFile" [string]$AssetName
)
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green if (-not (Test-Path $FilePath)) {
return
}
Write-Host "Lade $AssetName hoch..." -ForegroundColor Cyan
# Vorhandenes Asset mit gleichem Namen ermitteln und ggf. loeschen
$ExistingAssets = try {
Invoke-RestMethod -Uri $UploadUrl -Headers $Headers -Method Get
} catch {
@()
}
$Duplicate = $ExistingAssets | Where-Object { $_.name -eq $AssetName }
if ($Duplicate) {
Write-Host " [i] Ersetze existierendes Asset '$AssetName' (ID: $($Duplicate.id))..." -ForegroundColor DarkYellow
$DeleteUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets/$($Duplicate.id)"
Invoke-RestMethod -Uri $DeleteUrl -Headers $Headers -Method Delete | Out-Null
}
$PathNorm = $FilePath.Replace('\', '/')
$TargetUrl = "${UploadUrl}?name=$AssetName"
$Result = & curl.exe -sS -X POST $TargetUrl -H $AuthHeader -F "attachment=@$PathNorm"
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload von $AssetName fehlgeschlagen: $Result"
exit 1
}
Write-Host "[OK] $AssetName erfolgreich hochgeladen." -ForegroundColor Green
}
# Windows-Assets hochladen
Upload-ReleaseAsset -FilePath $ZipFile -AssetName "sanctum-$TagName-windows-x86_64.zip"
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
Upload-ReleaseAsset -FilePath $ExeFile -AssetName "sanctum.exe"
# Linux-Assets hochladen
$LinuxTar = Join-Path $DistDir "sanctum-$TagName-linux-x86_64.tar.gz"
Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_64.tar.gz"
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
# SHA256SUMS.txt hochladen # SHA256SUMS.txt hochladen
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
$SumResult = & curl.exe -s -X POST "$UploadUrl?name=SHA256SUMS.txt" `
-H "Authorization: token $Token" `
-F "attachment=@$ChecksumFile"
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
Write-Host "`n============================================================" -ForegroundColor Green Write-Host "`n============================================================" -ForegroundColor Green
+1046
View File
File diff suppressed because it is too large Load Diff
+433 -54
View File
@@ -78,11 +78,10 @@ pub fn generate_salt() -> [u8; 16] {
salt salt
} }
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM. /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. pub fn wrap_key_payload(
pub fn wrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
dek: &[u8; 32], payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek) let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -91,10 +90,10 @@ pub fn wrap_dek(
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec(); let mut buffer = payload.to_vec();
let tag = cipher let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) .encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?; .map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16]; let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice()); tag_bytes.copy_from_slice(tag.as_slice());
@@ -102,6 +101,36 @@ pub fn wrap_dek(
Ok((buffer, nonce_bytes, tag_bytes)) Ok((buffer, nonce_bytes, tag_bytes))
} }
/// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_key_payload(
kek: &[u8; 32],
wrapped_payload: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Zeroizing<Vec<u8>>> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität. /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek( pub fn unwrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
@@ -109,31 +138,47 @@ pub fn unwrap_dek(
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> { ) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len()); if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
} }
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_dek.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
let mut dek = Zeroizing::new([0u8; 32]); let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer); dek.copy_from_slice(&payload[0..32]);
Ok(dek) Ok(dek)
} }
/// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot0_payload(
kek: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(40);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot1_payload(
kek: &[u8; 32],
dek_1: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(72);
payload.extend_from_slice(dek_1);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie /// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter KDF/DEK-Slot. Dadurch sind Standard-Container von Containern mit Hidden Vault /// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). /// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) { pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
let mut wrapped_dek = vec![0u8; 32]; let mut wrapped_dek = vec![0u8; 72];
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
let mut tag = [0u8; 16]; let mut tag = [0u8; 16];
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
@@ -144,26 +189,42 @@ pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
(wrapped_dek, nonce, tag, salt) (wrapped_dek, nonce, tag, salt)
} }
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM. /// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault,
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind. /// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern:
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { /// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian).
#[inline]
pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
let mut aad = [0u8; 16];
aad[..8].copy_from_slice(b"SANCNAME");
aad[8..].copy_from_slice(&parent_id.to_le_bytes());
aad
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init"); let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
let mut buffer = name.as_bytes().to_vec(); let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = cipher let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer) .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
.expect("Name encryption"); .expect("Name encryption");
format!( let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
"$h${}${}${}", combined.extend_from_slice(&nonce_bytes);
hex::encode(nonce_bytes), combined.extend_from_slice(tag.as_slice());
hex::encode(tag.as_slice()), combined.extend_from_slice(&buffer);
hex::encode(&buffer) hex::encode(combined)
)
} }
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> { /// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") { if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect(); let parts: Vec<&str> = rest.split('$').collect();
if parts.len() == 3 { if parts.len() == 3 {
@@ -174,11 +235,13 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
) { ) {
if nonce_bytes.len() == 12 && tag_bytes.len() == 16 { if nonce_bytes.len() == 12 && tag_bytes.len() == 16 {
let cipher = Aes256Gcm::new_from_slice(dek).ok()?; let cipher = Aes256Gcm::new_from_slice(dek).ok()?;
let mut buffer = ct_bytes; // 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct_bytes.clone();
if cipher if cipher
.decrypt_in_place_detached( .decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes), Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME", &aad,
&mut buffer, &mut buffer,
Tag::from_slice(&tag_bytes), Tag::from_slice(&tag_bytes),
) )
@@ -186,6 +249,63 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
{ {
return String::from_utf8(buffer).ok(); return String::from_utf8(buffer).ok();
} }
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
return None;
}
// Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext)
if stored.len() >= 56 {
if let Ok(bytes) = hex::decode(stored) {
if bytes.len() >= 28 {
let nonce = &bytes[0..12];
let tag = &bytes[12..28];
let ct = &bytes[28..];
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
// 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
&aad,
&mut buffer,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Alte statische AAD für bestehende Container
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
} }
} }
} }
@@ -200,16 +320,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
Ok(mnemonic.to_string()) Ok(mnemonic.to_string())
} }
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. pub fn levenshtein_distance(a: &str, b: &str) -> usize {
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme. let a_chars: Vec<char> = a.chars().collect();
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> { let b_chars: Vec<char> = b.chars().collect();
let cleaned = phrase let (m, n) = (a_chars.len(), b_chars.len());
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned) let mut dp = vec![vec![0usize; n + 1]; m + 1];
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?; for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort.
pub fn suggest_bip39_word(word: &str) -> Option<&'static str> {
let word_list = bip39::Language::English.word_list();
let mut best_match = None;
let mut min_dist = usize::MAX;
for &valid in word_list {
let dist = levenshtein_distance(word, valid);
if dist < min_dist {
min_dist = dist;
best_match = Some(valid);
}
}
// Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2)
if min_dist <= 2 {
best_match
} else {
None
}
}
/// Normalisiert eine eingegebene BIP-39 Phrase:
/// - Wandelt in Kleinbuchstaben um (to_lowercase)
/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen
/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing)
pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
let cleaned: String = phrase
.chars()
.map(|c| {
if c.is_alphabetic() {
c.to_ascii_lowercase()
} else {
' '
}
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let words = normalize_mnemonic_phrase(phrase);
if words.is_empty() {
bail!("Die Notfallphrase darf nicht leer sein.");
}
if words.len() != 24 {
bail!(
"Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.",
words.len()
);
}
let word_list = bip39::Language::English.word_list();
let mut invalid_words = Vec::new();
for (idx, word) in words.iter().enumerate() {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
};
invalid_words.push(msg);
}
}
if !invalid_words.is_empty() {
bail!(
"Ungültige Wörter in der Notfallphrase festgestellt:\n • {}",
invalid_words.join("\n")
);
}
let normalized_phrase = words.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase)
.map_err(|_| {
anyhow::anyhow!(
"Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \
Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort."
)
})?;
let entropy = mnemonic.to_entropy(); let entropy = mnemonic.to_entropy();
if entropy.len() != 32 { if entropy.len() != 32 {
@@ -259,8 +484,8 @@ pub fn encrypt_chunk(
vec![COMPRESSION_NONE] vec![COMPRESSION_NONE]
} else { } else {
let compressed = lz4_flex::compress_prepend_size(plaintext); let compressed = lz4_flex::compress_prepend_size(plaintext);
// Nur komprimieren, wenn tatsächlich Bytes gespart werden (+1 Byte für das Flag) // Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag)
if compressed.len() + 1 < plaintext.len() { if compressed.len() + 64 <= plaintext.len() {
let mut buf = Vec::with_capacity(compressed.len() + 1); let mut buf = Vec::with_capacity(compressed.len() + 1);
buf.push(COMPRESSION_LZ4); buf.push(COMPRESSION_LZ4);
buf.extend_from_slice(&compressed); buf.extend_from_slice(&compressed);
@@ -316,7 +541,19 @@ pub fn decrypt_chunk(
match buffer[0] { match buffer[0] {
COMPRESSION_NONE => Ok(buffer[1..].to_vec()), COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
COMPRESSION_LZ4 => { COMPRESSION_LZ4 => {
let decompressed = lz4_flex::decompress_size_prepended(&buffer[1..]) let payload = &buffer[1..];
if payload.len() < 4 {
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
}
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
if uncompressed_size > CHUNK_SIZE {
bail!(
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
uncompressed_size,
CHUNK_SIZE
);
}
let decompressed = lz4_flex::decompress_size_prepended(payload)
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?; .map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
Ok(decompressed) Ok(decompressed)
} }
@@ -420,6 +657,22 @@ mod tests {
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
} }
#[test]
fn test_lz4_chunk_compression_threshold() {
let dek = generate_dek();
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
let mut random_bytes = vec![0u8; 1000];
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
assert_eq!(decrypted, random_bytes);
}
#[test] #[test]
fn test_v1_backward_compatibility() { fn test_v1_backward_compatibility() {
let dek = generate_dek(); let dek = generate_dek();
@@ -477,27 +730,153 @@ mod tests {
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
} }
#[test]
fn test_bip39_advanced_normalisation_and_suggestions() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).unwrap();
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
// 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization)
let uppercase_phrase = mnemonic_str.to_uppercase();
let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase");
assert_eq!(*dek, *recovered);
// 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern)
let punctuated = format!("{}.", words.join(", "));
let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated");
assert_eq!(*dek, *recovered_punct);
// 3. Typo-Vorschlag (Levenshtein)
let mut typo_words = words.clone();
typo_words[5] = "abondon"; // Typo for abandon
let typo_phrase = typo_words.join(" ");
let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string();
assert!(err.contains("Wort #6 'abondon' ist ungültig"));
assert!(err.contains("Meinten Sie 'abandon'?"));
}
#[test] #[test]
fn test_hidden_node_name_encryption_and_dummy_slot() { fn test_hidden_node_name_encryption_and_dummy_slot() {
let dek = generate_dek(); let dek = generate_dek();
let filename = "ultra_geheimes_dokument.pdf"; let filename = "ultra_geheimes_dokument.pdf";
let encrypted = encrypt_node_name(&dek, filename); let parent_id = 2i64;
assert!(encrypted.starts_with("$h$")); let encrypted = encrypt_node_name(&dek, parent_id, filename);
// Kein verräterisches Präfix mehr! Reines Hex.
assert!(!encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename)); assert!(!encrypted.contains(filename));
assert!(encrypted.len() >= 56);
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name"); let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename); assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap();
let mut static_buf = filename.as_bytes().to_vec();
let static_nonce = [42u8; 12];
let static_tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
.unwrap();
let legacy_static_format = format!(
"$h${}${}${}",
hex::encode(static_nonce),
hex::encode(static_tag),
hex::encode(&static_buf)
);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
assert_eq!(decrypted_static, filename);
// Mit anderem DEK schlägt Entschlüsselung fehl // Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek(); let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none());
// Dummy-Slot hat korrekte Längen // Dummy-Slot hat korrekte Längen (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
assert_eq!(dummy_dek.len(), 32); assert_eq!(dummy_dek.len(), 72);
assert_eq!(dummy_nonce.len(), 12); assert_eq!(dummy_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.len(), 16); assert_eq!(dummy_salt.len(), 16);
} }
#[test]
fn test_node_name_aad_parent_binding() {
let dek = generate_dek();
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
}
#[test]
fn test_model_a_slot_payloads() {
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let dek_0 = generate_dek();
let dek_1 = generate_dek();
let carrier_node_id = 42i64;
// Slot 0 Payload: 40 Bytes
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_0.len(), 40);
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
assert_eq!(unwrapped_0.len(), 40);
assert_eq!(&unwrapped_0[0..32], &*dek_0);
let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap());
assert_eq!(recovered_cid_0, carrier_node_id);
// Slot 1 Payload: 72 Bytes
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_1.len(), 72);
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
assert_eq!(unwrapped_1.len(), 72);
assert_eq!(&unwrapped_1[0..32], &*dek_1);
assert_eq!(&unwrapped_1[32..64], &*dek_0);
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
assert_eq!(recovered_cid_1, carrier_node_id);
}
#[test]
fn test_lz4_decompression_bomb_protection() {
use aes_gcm::KeyInit;
let dek = generate_dek();
let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap();
let node_id = 999;
let chunk_index = 0;
let aad = build_chunk_aad(node_id, chunk_index);
// Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE)
let mut malicious_plaintext = Vec::new();
malicious_plaintext.push(COMPRESSION_LZ4);
let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB
malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes());
malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload
let mut nonce_bytes = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut ct = malicious_plaintext.clone();
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
let err_msg = res.err().unwrap().to_string();
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
}
} }
+2
View File
@@ -1,5 +1,7 @@
pub mod carrier;
pub mod crypto; pub mod crypto;
pub mod mount; pub mod mount;
pub mod platform;
pub mod recovery; pub mod recovery;
pub mod storage; pub mod storage;
pub mod ui; pub mod ui;
+374 -124
View File
@@ -3,14 +3,15 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use zeroize::Zeroizing;
use sanctum::crypto::{ use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek, dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
wrap_dek, KdfParams, FORMAT_VERSION, wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{ use sanctum::recovery::{
export_header_backup, restore_header_backup, restore_header_from_recovery_key, export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
}; };
use sanctum::storage::Database; use sanctum::storage::Database;
use sanctum::ui; use sanctum::ui;
@@ -37,6 +38,17 @@ enum Commands {
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability) /// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
with_hidden: bool, with_hidden: bool,
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
/// in forensischen Entropie-Scannern zu verhindern.
#[arg(long, default_value = "system_backup.dat")]
carrier_name: String,
/// Größe der Trägerdatei (z. B. 100MB, 500MB, 1GB, 2GB; Standard: 1GB)
#[arg(long, default_value = "1GB")]
carrier_size: String,
}, },
/// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten /// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
@@ -56,16 +68,22 @@ enum Commands {
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, path: PathBuf,
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt standardmäßig automatisch das nächste freie Laufwerk) /// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt unter Windows standardmäßig das nächste freie Laufwerk)
#[arg(short, long)] #[arg(short, long)]
drive: Option<String>, drive: Option<String>,
/// Optionaler lokaler Mount-Pfad unter Linux/macOS (z. B. '/mnt/sanctum' oder '~/vault')
#[arg(short = 'm', long = "mount-point", value_name = "DIR")]
mount_point: Option<PathBuf>,
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443) /// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
#[arg(long)] #[arg(long)]
port: Option<u16>, port: Option<u16>,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage) /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage).
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel sicher und interaktiv
/// einzugeben (verhindert Klartext-Einträge in der PowerShell-Historie).
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer /// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
@@ -87,6 +105,10 @@ enum Commands {
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini) /// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
no_anti_leak: bool, no_anti_leak: bool,
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
#[arg(short, long, default_value_t = false)]
stealth: bool,
}, },
/// Trennt ein eingebundenes Netzlaufwerk manuell /// Trennt ein eingebundenes Netzlaufwerk manuell
@@ -102,11 +124,34 @@ enum Commands {
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, path: PathBuf,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort) /// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort).
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
}, },
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
Backup {
/// Pfad zur Quelldatei (.sanctum Containerdatei)
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Ziel-Backup-Datei (.sanctum.bak)
#[arg(short, long)]
output: PathBuf,
},
/// Stellt einen Container aus einem Backup wieder her (inklusive B-Tree Integritätsprüfung)
Restore {
/// Pfad zur Backup-Datei (.sanctum.bak)
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Ziel-Containerdatei (.sanctum)
#[arg(short, long)]
output: PathBuf,
},
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr) /// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
BackupHeader { BackupHeader {
/// Pfad zur .sanctum Containerdatei /// Pfad zur .sanctum Containerdatei
@@ -128,9 +173,14 @@ enum Commands {
#[arg(long)] #[arg(long)]
header_file: Option<PathBuf>, header_file: Option<PathBuf>,
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort /// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort.
#[arg(long)] /// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>, recovery_key: Option<String>,
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
#[arg(long, default_value_t = 0)]
slot: u32,
}, },
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an /// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
@@ -172,7 +222,43 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase()) Ok(ch.to_ascii_uppercase())
} }
fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { pub fn parse_size_string(s: &str) -> Result<u64> {
let trimmed = s.trim().to_uppercase();
if let Some(num_str) = trimmed.strip_suffix("GB") {
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
Ok(n * 1024 * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
Ok(n * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
Ok(n * 1024)
} else if let Ok(n) = trimmed.parse::<u64>() {
Ok(n)
} else {
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
}
}
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan(""));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der PowerShell-Historie.", ui::dim("OpSec"));
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
.context("Fehler beim Einlesen des Notfallschlüssels")?;
if phrase.trim().is_empty() {
bail!("Der Notfallschlüssel darf nicht leer sein.");
}
Ok(Zeroizing::new(phrase))
}
fn handle_init(
container_path: &Path,
with_hidden: bool,
carrier_name: &str,
carrier_size_str: &str,
) -> Result<()> {
if container_path.exists() { if container_path.exists() {
bail!( bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
@@ -185,37 +271,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 +340,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 +375,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 +401,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 +431,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 +530,21 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let (dek, target_slot_id) = if let Some(phrase) = recovery_key { let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
let actual_phrase = if phrase.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(phrase.to_string())
};
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0) (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 +556,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 +591,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 +660,79 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re
Ok(()) Ok(())
} }
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
if output_path.exists() {
bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Online-Backup erstellen (Hot-Backup) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!(" Backup-Ziel: {}", output_path.display());
println!();
ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream...");
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API...");
db.online_backup(output_path)
.context("Fehler beim Erstellen des Online-Backups")?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Online-Backup erfolgreich abgeschlossen! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Backup-Datei: {}", output_path.display());
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
println!();
Ok(())
}
fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
if !backup_path.exists() {
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
if output_path.exists() {
bail!("Zieldatei '{}' existiert bereits. Wiederherstellung abgebrochen, um Überschreiben zu verhindern.", output_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container aus Backup wiederherstellen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Backup-Quelle: {}", backup_path.display());
println!(" Container-Ziel: {}", output_path.display());
println!();
ui::step(1, 2, "📦", "Kopiere Backup-Seiten in Ziel-Container...");
Database::restore_from_backup(backup_path, output_path)
.context("Fehler bei der Container-Wiederherstellung aus dem Backup")?;
ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!");
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Container erfolgreich aus Backup wiederhergestellt! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Neuer Container: {}", output_path.display());
println!(" • Integrität: Vollständig intakt & bereit zum Mounten");
println!();
Ok(())
}
fn handle_restore_header( fn handle_restore_header(
container_path: &Path, container_path: &Path,
header_file: Option<&Path>, header_file: Option<&Path>,
recovery_key: Option<&str>, recovery_key: Option<&str>,
slot: u32,
) -> Result<()> { ) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Header wiederherstellen │"); println!("│ Sanctum — Container-Header wiederherstellen │");
@@ -545,29 +747,61 @@ fn handle_restore_header(
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!"); ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
println!(); println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")); println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
} else if let Some(key) = recovery_key { } else {
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel..."); let key_str = if let Some(key) = recovery_key {
let new_password = rpassword::prompt_password("Neues Master-Passwort festlegen: ") if key.trim().is_empty() {
.context("Fehler beim Einlesen des Passworts")?; prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key.to_string())
}
} else {
// Interaktiver Auswahldialog statt Fehlermeldung
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
println!(" Bitte wählen Sie die gewünschte Wiederherstellungsmethode:");
println!(" [1] Sicherungsdatei verwenden (.sanctum.hdr)");
println!(" [2] 24-Wort BIP-39 Notfallschlüssel interaktiv eingeben");
print!(" Auswahl [1/2]: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut choice = String::new();
std::io::stdin().read_line(&mut choice)?;
match choice.trim() {
"1" => {
print!(" Pfad zur .sanctum.hdr Sicherungsdatei: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut p = String::new();
std::io::stdin().read_line(&mut p)?;
let hdr_path = PathBuf::from(p.trim());
return handle_restore_header(container_path, Some(&hdr_path), None, slot);
}
"2" => prompt_recovery_key_interactive()?,
_ => bail!("Ungültige Auswahl. Wiederherstellung abgebrochen."),
}
};
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
let new_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if new_password.trim().is_empty() { if new_password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein."); bail!("Das Master-Passwort darf nicht leer sein.");
} }
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ") let confirm_password = Zeroizing::new(
.context("Fehler beim Einlesen der Passwort-Bestätigung")?; rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
);
if new_password != confirm_password { if *new_password != *confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!"); bail!("Die eingegebenen Passwörter stimmen nicht überein!");
} }
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_header_from_recovery_key(container_path, key, &new_password)?; restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?;
ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!"); ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
println!(); println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")); println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
} else {
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
} }
println!(); println!();
@@ -585,29 +819,21 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
println!(" Container: {}", container_path.display()); println!(" Container: {}", container_path.display());
println!(); println!();
let password = rpassword::prompt_password("Master-Passwort eingeben: ") let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let mut found = None; let keys = meta
for slot in &meta.slots { .authenticate(&password)
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) { .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { let dek = keys.dek().clone();
found = Some((dek, slot.slot_id));
break;
}
}
}
let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?; let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
}
ui::print_recovery_phrase_card(&phrase); ui::print_recovery_phrase_card(&phrase);
Ok(()) Ok(())
@@ -624,27 +850,19 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
println!(" Container: {}", container_path.display()); println!(" Container: {}", container_path.display());
println!(); println!();
let password = rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ") let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ")
.context("Fehler beim Einlesen des Passworts")?,
);
let dek = if !password.trim().is_empty() { let dek = if !password.trim().is_empty() {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?; let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
let meta = db.read_meta().context("Konnte Header nicht lesen")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?;
let mut found = None; match meta.authenticate(&password) {
for slot in &meta.slots { Some(keys) => {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some(d);
break;
}
}
}
match found {
Some(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("")); println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
Some(d) Some(keys.dek().clone())
} }
None => { None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
@@ -670,15 +888,27 @@ 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,
mount_point,
port, port,
recovery_key, recovery_key,
no_open, no_open,
@@ -686,38 +916,56 @@ async fn run() -> Result<()> {
idle_timeout, idle_timeout,
no_screen_lock, no_screen_lock,
no_anti_leak, no_anti_leak,
stealth,
} => { } => {
let drive_char = match drive { let drive_char = match drive {
Some(ref d) => parse_drive_letter(d)?, Some(ref d) => parse_drive_letter(d)?,
None => { None => {
let auto_drive = sanctum::windows::find_next_available_drive()?; #[cfg(windows)]
println!( {
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:", let auto_drive = sanctum::windows::find_next_available_drive()?;
ui::cyan(""), if !stealth {
println!(
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
ui::cyan(""),
auto_drive
);
}
auto_drive auto_drive
); }
auto_drive #[cfg(not(windows))]
{
'Z'
}
} }
}; };
let auth = if let Some(key) = recovery_key { let auth = if let Some(key) = recovery_key {
ContainerAuth::RecoveryKey(key) let actual_key = if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key)
};
ContainerAuth::RecoveryKey(actual_key)
} else { } else {
let prompt_text = format!( let prompt_text = format!(
"Master-Passwort für Container '{}' eingeben: ", "Master-Passwort für Container '{}' eingeben: ",
path.display() path.display()
); );
let password = rpassword::prompt_password(prompt_text) let password = Zeroizing::new(
.context("Fehler beim Einlesen des Passworts")?; rpassword::prompt_password(prompt_text)
.context("Fehler beim Einlesen des Passworts")?,
);
ContainerAuth::Password(password) ContainerAuth::Password(password)
}; };
let open_explorer = !no_open; let open_explorer = !no_open && !stealth;
let enable_tray = !no_tray; let enable_tray = !no_tray && !stealth;
let lock_on_screen_lock = !no_screen_lock; let lock_on_screen_lock = !no_screen_lock;
let anti_leak = !no_anti_leak; let anti_leak = !no_anti_leak;
mount_container( mount_container(
&path, &path,
drive_char, drive_char,
mount_point.as_deref(),
port, port,
auth, auth,
open_explorer, open_explorer,
@@ -725,6 +973,7 @@ async fn run() -> Result<()> {
idle_timeout, idle_timeout,
lock_on_screen_lock, lock_on_screen_lock,
anti_leak, anti_leak,
stealth,
) )
.await?; .await?;
} }
@@ -747,8 +996,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)?;
+543 -199
View File
@@ -13,8 +13,9 @@ use hyper_util::rt::TokioIo;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, warn}; use tracing::{debug, warn};
use zeroize::Zeroizing;
use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek}; use crate::crypto::mnemonic_to_dek;
use crate::storage::Database; use crate::storage::Database;
use crate::ui; use crate::ui;
use crate::vfs::SanctumFs; use crate::vfs::SanctumFs;
@@ -22,8 +23,8 @@ use crate::vfs::SanctumFs;
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel. /// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ContainerAuth { pub enum ContainerAuth {
Password(String), Password(Zeroizing<String>),
RecoveryKey(String), RecoveryKey(Zeroizing<String>),
} }
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
@@ -31,56 +32,119 @@ pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase()) format!("{}:", drive_letter.to_ascii_uppercase())
} }
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y`. /// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
pub fn unmount_drive(drive_letter: char) -> Result<()> { pub fn unmount_drive(drive_letter: char) -> Result<()> {
let drive_str = format_drive(drive_letter); #[cfg(windows)]
{
let drive_str = format_drive(drive_letter);
let output = Command::new("net") let output = Command::new("net")
.args(["use", &drive_str, "/delete", "/y"]) .args(["use", &drive_str, "/delete", "/y"])
.output() .output()
.context("Fehler beim Ausführen des Befehls 'net use'")?; .context("Fehler beim Ausführen des Befehls 'net use'")?;
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);
bail!( bail!(
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}", "Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
drive_str, drive_str,
stdout, stdout,
stderr stderr
); );
}
Ok(())
}
#[cfg(unix)]
{
let _ = drive_letter;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = drive_letter;
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<()> { /// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
let url = format!("http://127.0.0.1:{}/", port); fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
#[cfg(windows)]
{
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'")?;
if !output.status.success() { // PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr);
bail!( let stdout = String::from_utf8_lossy(&output.stdout);
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}", let is_already_in_use = stderr.contains("85")
drive_str, || stderr.contains("bereits verwendet")
stdout, || stderr.contains("already in use")
stderr || 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!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
drive_str,
stdout,
stderr,
webclient_hint
);
}
Ok(())
}
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = drive_str;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = (drive_str, port, session_token);
Ok(())
} }
Ok(())
} }
/// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk ein. /// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk (Windows) bzw. VFS (Linux) ein.
pub async fn mount_container( pub async fn mount_container(
container_path: &Path, container_path: &Path,
drive_letter: char, drive_letter: char,
mount_point: Option<&Path>,
requested_port: Option<u16>, requested_port: Option<u16>,
auth: ContainerAuth, auth: ContainerAuth,
open_explorer: bool, open_explorer: bool,
@@ -88,8 +152,13 @@ pub async fn mount_container(
idle_timeout: Option<u64>, idle_timeout: Option<u64>,
lock_on_screen_lock: bool, lock_on_screen_lock: bool,
anti_leak: bool, anti_leak: bool,
stealth: bool,
) -> Result<()> { ) -> Result<()> {
let drive_str = format_drive(drive_letter); let drive_str = format_drive(drive_letter);
let _ = mount_point;
let _ = open_explorer;
let _ = enable_tray;
let _ = lock_on_screen_lock;
if !container_path.exists() { if !container_path.exists() {
bail!( bail!(
@@ -98,8 +167,10 @@ pub async fn mount_container(
); );
} }
println!(); if !stealth {
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
@@ -107,29 +178,21 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
let (dek, version, vault_id) = match auth { let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => { ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); if !stealth {
let mut unwrapped = None; ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
for slot in &meta.slots {
if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(
&kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
unwrapped = Some((dek, slot.version, slot.slot_id));
break;
}
}
} }
match meta.authenticate(password) {
match unwrapped { Some(keys) => {
Some((dek, ver, slot_id)) => { if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id) }
let vault_id = keys.slot_id();
let ver = keys.version();
let c_dek = keys.carrier_dek();
let c_nid = keys.carrier_node_id();
(keys.0, c_dek, c_nid, ver, vault_id)
} }
None => { None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt"); bail!("Ungültiges Master-Passwort oder Container beschädigt");
@@ -137,28 +200,44 @@ pub async fn mount_container(
} }
} }
ContainerAuth::RecoveryKey(ref phrase) => { ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase) let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?; .context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
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 }
} else {
0
}; };
(dek, meta.version, vault_id) let vault_id = if is_hidden { 1 } else { 0 };
(dek, None, None, meta.version, vault_id)
} }
}; };
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing) // 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
let token_path_prefix = format!("/{}", session_token);
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
db.clone(),
dek,
carrier_dek,
carrier_node_id,
version,
anti_leak,
vault_id,
);
let last_activity = fs.last_activity(); let last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
.locksystem(FakeLs::new()) .locksystem(FakeLs::new())
.build_handler(); .build_handler();
@@ -180,71 +259,55 @@ pub async fn mount_container(
let bound_addr = listener.local_addr()?; let bound_addr = listener.local_addr()?;
let bound_port = bound_addr.port(); let bound_port = bound_addr.port();
ui::step( if !stealth {
4, #[cfg(windows)]
4, ui::step(
"🌐", 4,
&format!( 4,
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", "🌐",
bound_port, drive_str &format!(
), "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
); bound_port, drive_str
),
);
#[cfg(not(windows))]
ui::step(
4,
4,
"🌐",
&format!(
"Starte WebDAV-Server auf Port {} & initialisiere VFS...",
bound_port
),
);
}
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); // Netzlaufwerk bzw. Verzeichnis einbinden
let handler = server_dav.clone(); if let Err(e) = run_mount_command(&drive_str, bound_port, &session_token) {
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
if let Err(e) = run_net_use_mount(&drive_str, bound_port) {
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
let _ = server_handle.await; let _ = server_handle.await;
return Err(e); return Err(e);
} }
// Optional automatisch im Windows Explorer öffnen // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
#[cfg(windows)]
if open_explorer { if open_explorer {
let _ = crate::windows::open_in_explorer(drive_letter); let _ = crate::windows::open_in_explorer(drive_letter);
} }
// System-Tray Initialisierung // System-Tray Initialisierung (nur Windows)
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); #[cfg(windows)]
let (_tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)] #[cfg(windows)]
let _tray = if enable_tray { let _tray = if enable_tray {
let icon_source = crate::windows::get_default_system_icon() let icon_source = crate::windows::get_default_system_icon()
@@ -262,7 +325,7 @@ pub async fn mount_container(
let _ = tray.add_menu_item("Im Explorer öffnen", move || { let _ = tray.add_menu_item("Im Explorer öffnen", move || {
let _ = crate::windows::open_in_explorer(dl); let _ = crate::windows::open_in_explorer(dl);
}); });
let s_tx = tray_shutdown_tx.clone(); let s_tx = _tray_shutdown_tx.clone();
let _ = tray.add_menu_item("Trennen & Beenden", move || { let _ = tray.add_menu_item("Trennen & Beenden", move || {
let _ = s_tx.blocking_send(()); let _ = s_tx.blocking_send(());
}); });
@@ -301,8 +364,10 @@ pub async fn mount_container(
} }
} }
// Windows-Sitzungssperre (Win + L Auto-Lock) // Windows-Sitzungssperre (Win + L Auto-Lock, nur Windows)
#[cfg(windows)]
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1); let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _session_monitor = if lock_on_screen_lock { let _session_monitor = if lock_on_screen_lock {
match crate::windows::start_session_lock_monitor(session_lock_tx) { match crate::windows::start_session_lock_monitor(session_lock_tx) {
Ok(guard) => Some(guard), Ok(guard) => Some(guard),
@@ -315,91 +380,370 @@ pub async fn mount_container(
None None
}; };
println!(); if stealth {
println!("┌─────────────────────────────────────────────────────────────┐"); #[cfg(windows)]
println!("│ ✔ Sanctum Container erfolgreich gemountet │"); println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
println!("└─────────────────────────────────────────────────────────────┘"); #[cfg(not(windows))]
println!(); println!("Sanctum: WebDAV-Server auf Port {} bereit.", bound_port);
println!(" • Container: {}", container_path.display()); } else {
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!();
println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); println!("┌─────────────────────────────────────────────────────────────┐");
if vault_id == 1 { println!("│ ✔ Sanctum Container erfolgreich gemountet │");
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)")); println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
#[cfg(windows)]
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
#[cfg(not(windows))]
{
if let Some(mp) = mount_point {
println!(" • Mountpoint: {}", ui::cyan(&mp.display().to_string()));
} else {
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/{}/", bound_port, session_token);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/{}/ {}", bound_port, session_token, mp.display());
}
}
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
#[cfg(windows)]
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
#[cfg(windows)]
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
#[cfg(not(windows))]
println!(" • Anti-Leak: VFS-Metadatenfilter aktiv (.directory, .Trash, desktop.ini blockiert)");
}
#[cfg(windows)]
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
#[cfg(windows)]
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
#[cfg(not(windows))]
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
println!();
} }
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
}
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L) // Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT, nur Windows)
#[cfg(windows)]
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Unix Signale (SIGTERM, SIGHUP)
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
#[cfg(windows)]
tokio::select! { tokio::select! {
res = tokio::signal::ctrl_c() => { res = tokio::signal::ctrl_c() => {
let _ = res; let _ = res;
println!(); if !stealth {
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
} }
_ = tray_shutdown_rx.recv() => { Some(()) = console_close_rx.recv() => {
println!(); if !stealth {
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); println!();
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
}
} }
_ = session_lock_rx.recv() => { Some(()) = tray_shutdown_rx.recv() => {
println!(); if !stealth {
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
}
} }
_ = idle_shutdown_rx.recv() => { Some(()) = session_lock_rx.recv() => {
println!(); if !stealth {
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
} }
} Some(()) = idle_shutdown_rx.recv() => {
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); if !stealth {
let _ = std::io::Write::flush(&mut std::io::stdout()); println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
} }
} }
} }
// SQLite WAL Checkpoint erzwingen #[cfg(unix)]
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); tokio::select! {
let _ = std::io::Write::flush(&mut std::io::stdout()); res = tokio::signal::ctrl_c() => {
if let Err(e) = db.checkpoint() { let _ = res;
println!("{}", ui::red(&format!("Fehler ({e})"))); if !stealth {
} else { println!();
println!("{}", ui::green("OK")); println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
}
_ = async {
match sigterm.as_mut() {
Some(s) => { s.recv().await; }
None => { std::future::pending::<()>().await; }
}
} => {
if !stealth {
println!();
println!(" {} Beendigungssignal (SIGTERM) empfangen.", ui::yellow("[!]"));
}
}
_ = async {
match sighup.as_mut() {
Some(s) => { s.recv().await; }
None => { std::future::pending::<()>().await; }
}
} => {
if !stealth {
println!();
println!(" {} Beendigungssignal (SIGHUP) empfangen.", ui::yellow("[!]"));
}
}
Some(()) = idle_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
} }
println!(); #[cfg(not(any(windows, unix)))]
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("")); tokio::select! {
println!(); res = tokio::signal::ctrl_c() => {
let _ = res;
if !stealth {
println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
}
Some(()) = idle_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
}
if stealth {
let _ = unmount_drive(drive_letter);
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
let _ = db.incremental_vacuum(None);
}
}
let _ = db.checkpoint();
#[cfg(windows)]
println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str);
#[cfg(not(windows))]
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
} else {
#[cfg(windows)]
{
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
}
#[cfg(not(windows))]
{
print!(" {} Beende WebDAV-Server ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout());
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
}
}
}
// SQLite WAL Checkpoint erzwingen
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout());
if let Err(e) = db.checkpoint() {
println!("{}", ui::red(&format!("Fehler ({e})")));
} else {
println!("{}", ui::green("OK"));
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!();
}
Ok(()) Ok(())
} }
/// Ü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:");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Plattform-Abstraktionsschicht für Sanctum.
//!
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
pub use crate::windows::*;
+195 -68
View File
@@ -6,13 +6,26 @@ use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::crypto::{ use crate::crypto::{
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use crate::storage::{ContainerMeta, Database, SlotMeta}; use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1; pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für einen gesicherten Header-Slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotBackup {
pub slot_id: u32,
pub version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
}
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format. /// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup { pub struct HeaderBackup {
@@ -24,6 +37,8 @@ pub struct HeaderBackup {
pub wrapped_dek_hex: String, pub wrapped_dek_hex: String,
pub header_nonce_hex: String, pub header_nonce_hex: String,
pub header_tag_hex: String, pub header_tag_hex: String,
#[serde(default)]
pub slots: Vec<SlotBackup>,
pub created_at: u64, pub created_at: u64,
} }
@@ -34,6 +49,20 @@ impl HeaderBackup {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let slots: Vec<SlotBackup> = meta
.slots
.iter()
.map(|s| SlotBackup {
slot_id: s.slot_id,
version: s.version,
kdf_salt_hex: hex::encode(s.kdf_salt),
kdf_params: s.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
header_nonce_hex: hex::encode(s.header_nonce),
header_tag_hex: hex::encode(s.header_tag),
})
.collect();
Self { Self {
magic: HEADER_BACKUP_MAGIC.to_string(), magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION, backup_version: CURRENT_BACKUP_VERSION,
@@ -43,6 +72,7 @@ impl HeaderBackup {
wrapped_dek_hex: hex::encode(&meta.wrapped_dek), wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce), header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag), header_tag_hex: hex::encode(meta.header_tag),
slots,
created_at: now, created_at: now,
} }
} }
@@ -52,51 +82,97 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix"); bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
} }
let salt_bytes = hex::decode(&self.kdf_salt_hex) let mut slots = Vec::new();
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 { if !self.slots.is_empty() {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len()); 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)
.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(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.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(&self.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);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
slots.push(slot0);
} }
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.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(&self.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);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
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);
slot_id: 0,
version: FORMAT_VERSION, let mut existing_slots = db.read_slots().unwrap_or_default();
kdf_salt: salt,
kdf_params: kdf_params.clone(), let new_slot = if target_slot_id == 1 {
wrapped_dek: wrapped_dek.clone(), // Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
header_nonce, let dek_0_bytes = [0u8; 32];
header_tag, 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,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped,
header_nonce: nonce,
header_tag: tag,
}
}; };
// Slot ersetzen bzw. einfügen
existing_slots.retain(|s| s.slot_id != target_slot_id);
existing_slots.push(new_slot);
existing_slots.sort_by_key(|s| s.slot_id);
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
if !existing_slots.iter().any(|s| s.slot_id == 1) {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_slot = SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
kdf_salt: dummy_salt,
kdf_params: KdfParams::default(),
wrapped_dek: dummy_dek,
header_nonce: dummy_nonce,
header_tag: dummy_tag,
};
existing_slots.push(dummy_slot);
}
let slot0_meta = existing_slots
.iter()
.find(|s| s.slot_id == 0)
.ok_or_else(|| anyhow::anyhow!("Slot 0 fehlt im Header"))?;
let meta = ContainerMeta { let meta = ContainerMeta {
version: FORMAT_VERSION, version: FORMAT_VERSION,
kdf_salt: salt, kdf_salt: slot0_meta.kdf_salt,
kdf_params, kdf_params: slot0_meta.kdf_params.clone(),
wrapped_dek, wrapped_dek: slot0_meta.wrapped_dek.clone(),
header_nonce, header_nonce: slot0_meta.header_nonce,
header_tag, header_tag: slot0_meta.header_tag,
slots: vec![slot0], slots: existing_slots,
}; };
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta) db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?; .context("Fehler beim Schreiben des rekonstruierten Headers")?;
@@ -209,10 +327,19 @@ pub fn restore_header_from_recovery_key(
Ok(()) Ok(())
} }
/// Rekonstruiert den Decoy-Slot (Slot 0) mithilfe des 24-Wort BIP-39 Notfallschlüssels.
pub fn restore_header_from_recovery_key(
container_path: &Path,
recovery_key: &str,
new_password: &str,
) -> Result<()> {
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::crypto::{generate_dek, generate_salt, unwrap_dek}; use crate::crypto::{generate_dek, generate_salt, unwrap_dek, wrap_dek};
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
+561 -56
View File
@@ -8,9 +8,10 @@ use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{ use crate::crypto::{
decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION, decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
}; };
use zeroize::Zeroizing;
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -45,6 +46,36 @@ pub struct SlotMeta {
pub header_tag: [u8; 16], pub header_tag: [u8; 16],
} }
/// Ergebnis einer erfolgreichen Authentifizierung eines Container-Slots.
/// Die Tupel-Struktur (0: DEK, 1: Version, 2: Slot-ID, 3: Carrier-DEK, 4: Carrier-Node-ID)
/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`).
#[derive(Clone)]
pub struct UnlockedKeys(
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
pub u32, // 1: Formatversion
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
pub Option<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
);
impl UnlockedKeys {
pub fn dek(&self) -> &Zeroizing<[u8; 32]> {
&self.0
}
pub fn version(&self) -> u32 {
self.1
}
pub fn slot_id(&self) -> u32 {
self.2
}
pub fn carrier_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
self.3.clone()
}
pub fn carrier_node_id(&self) -> Option<i64> {
self.4
}
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContainerMeta { pub struct ContainerMeta {
@@ -57,6 +88,61 @@ pub struct ContainerMeta {
pub slots: Vec<SlotMeta>, pub slots: Vec<SlotMeta>,
} }
impl ContainerMeta {
/// Authentifiziert ein Master-Passwort über alle Header-Slots in strikt konstanter Zeit (Anti-Timing Side-Channel).
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
let mut matching = None;
for slot in &self.slots {
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
.ok()
.and_then(|kek| {
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
});
if let Some(payload) = res {
if matching.is_none() {
if slot.slot_id == 0 {
let mut dek = Zeroizing::new([0u8; 32]);
let carrier_node_id = if payload.len() >= 40 {
dek.copy_from_slice(&payload[0..32]);
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
if cid > 0 { Some(cid) } else { None }
} else if payload.len() >= 32 {
dek.copy_from_slice(&payload[0..32]);
None
} else {
continue;
};
matching = Some(UnlockedKeys(dek, slot.version, 0, None, carrier_node_id));
} else if slot.slot_id == 1 {
let mut dek_1 = Zeroizing::new([0u8; 32]);
let mut dek_0 = Zeroizing::new([0u8; 32]);
let (carrier_dek, carrier_node_id) = if payload.len() >= 72 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
let cid = i64::from_le_bytes(payload[64..72].try_into().unwrap());
(Some(dek_0), if cid > 0 { Some(cid) } else { None })
} else if payload.len() >= 64 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
(Some(dek_0), None)
} else if payload.len() >= 32 {
dek_1.copy_from_slice(&payload[0..32]);
(None, None)
} else {
continue;
};
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
}
}
}
}
matching
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
@@ -72,7 +158,25 @@ fn current_timestamp() -> u64 {
impl Database { impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas. /// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let conn = Connection::open(path)?; let path_ref = path.as_ref();
let conn = match Connection::open(path_ref) {
Ok(c) => c,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("Access is denied")
|| err_str.contains("permission denied")
|| err_str.contains("os error 5")
{
bail!(
"Zugriff auf '{}' verweigert (OS Fehler 5 / Access Denied).\n\
[!] Möglicherweise blockiert durch Windows Defender 'Überwachter Ordnerzugriff' (Controlled Folder Access).\n\
[i] Abhilfe: Fügen Sie 'sanctum.exe' in den Windows-Sicherheitseinstellungen (Viren- & Bedrohungsschutz -> Ransomware-Schutz -> Überwachter Ordnerzugriff) als erlaubte App hinzu, oder platzieren Sie den Container außerhalb geschützter Benutzerordner.",
path_ref.display()
);
}
return Err(e.into());
}
};
let db = Self { let db = Self {
conn: Arc::new(Mutex::new(conn)), conn: Arc::new(Mutex::new(conn)),
}; };
@@ -97,7 +201,13 @@ impl Database {
self.conn.lock().unwrap() self.conn.lock().unwrap()
} }
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalten slot_id und vault_id, auto_vacuum) durch. /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?;
Ok(meta.authenticate(password))
}
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
pub fn ensure_schema_upgrades(&self) -> Result<()> { pub fn ensure_schema_upgrades(&self) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0); let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
@@ -106,16 +216,9 @@ impl Database {
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
} }
// Spalte slot_id in meta // Spalte slot_id in meta (falls aus v1 migriert)
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []); let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
// Spalte vault_id in nodes
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_vault_parent ON nodes(vault_id, parent_id)", []);
// Spalte vault_id in chunks
let _ = conn.execute("ALTER TABLE chunks ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
Ok(()) Ok(())
} }
@@ -168,6 +271,34 @@ impl Database {
Ok(count as usize) Ok(count as usize)
} }
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
)?;
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
Ok(id)
}
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
if node_id == ancestor_id {
return Ok(true);
}
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(id) AS (
SELECT id FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
)?;
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
Ok(exists.is_some())
}
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding). /// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
@@ -195,21 +326,238 @@ impl Database {
Ok(()) Ok(())
} }
/// Prüft, ob ein Hidden Vault (Wurzelknoten 2 mit vault_id = 1) im Container existiert. /// Initialisiert das Datenbankschema für Modell A (Alibi-Carrier / Steganografischer Tresor).
pub fn has_hidden_vault(&self) -> Result<bool> { /// Legt die Trägerdatei im Decoy-Vault an und allokiert alle Carrier-Chunks mit initialem Rauschen.
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - Slot 0: 40 Bytes gewrappter Payload (32B DEK_0 || 8B carrier_node_id)
/// - Slot 1: 72 Bytes gewrappter Payload (32B DEK_1 || 32B DEK_0 || 8B carrier_node_id oder CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - 0 unzugeordnete Chunks: 100% aller Chunks gehören zu legitimen Decoy-Inodes und authentifizieren fehlerfrei unter DEK_0!
pub fn init_schema_with_carrier(
&self,
salt_0: &[u8; 16],
kdf_params_0: &KdfParams,
wrapped_dek_0: &[u8],
header_nonce_0: &[u8; 12],
header_tag_0: &[u8; 16],
carrier_config: Option<(
&str, // carrier_name
u64, // carrier_size_bytes
&[u8; 16], // salt_1
&KdfParams, // kdf_params_1
&[u8], // wrapped_dek_1 (72B)
&[u8; 12], // header_nonce_1
&[u8; 16], // header_tag_1
&[u8; 32], // raw DEK_0
&[u8; 32], // raw DEK_1
)>,
) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let exists: bool = conn
.query_row( conn.execute_batch(
"SELECT 1 FROM nodes WHERE id = 2 AND vault_id = 1 LIMIT 1", "CREATE TABLE IF NOT EXISTS meta (
[], slot_id INTEGER NOT NULL PRIMARY KEY,
|_| Ok(true), magic BLOB NOT NULL,
) version INTEGER NOT NULL,
.optional()? kdf_salt BLOB NOT NULL,
.unwrap_or(false); kdf_params TEXT NOT NULL,
Ok(exists) wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index),
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);",
)?;
// Slot 0 einfügen (Standard / Decoy Vault)
let params_json_0 = serde_json::to_string(kdf_params_0)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
salt_0.as_slice(),
params_json_0,
wrapped_dek_0,
header_nonce_0.as_slice(),
header_tag_0.as_slice(),
],
)?;
let now = current_timestamp();
// Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
let carrier_node_id = if let Some((
c_name,
c_size,
h_salt,
h_params,
h_wrapped,
h_nonce,
h_tag,
dek_0,
dek_1,
)) = carrier_config
{
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, ?1, 0, ?2, ?3, ?4)",
params![c_name, c_size as i64, now, now],
)?;
let c_id = conn.last_insert_rowid();
// Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten)
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32;
// Slot 1 (Hidden Vault) einfügen
let params_json_1 = serde_json::to_string(h_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
h_salt.as_slice(),
params_json_1,
h_wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)?;
// Initialisiere CarrierManifest für Block 0
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
let manifest_bytes = serde_json::to_vec(&manifest)?;
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
dek_1,
c_id,
0,
&manifest_bytes,
FORMAT_VERSION,
)?;
let inner_ct_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext);
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
outer_plaintext[12..28].copy_from_slice(&inner_tag);
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
let ct_end = 32 + inner_ct.len();
if ct_end > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 0");
}
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
0,
&outer_plaintext,
FORMAT_VERSION,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
)?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut dummy_noise);
conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 1..total_blocks {
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
b,
&dummy_noise,
FORMAT_VERSION,
)?;
chunk_stmt.execute(params![
c_id,
b,
nonce.as_slice(),
tag.as_slice(),
ct,
])?;
if b % 500 == 0 {
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
}
}
conn.execute_batch("COMMIT;")?;
Some(c_id)
} else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
dummy_salt.as_slice(),
dummy_params_json,
dummy_dek.as_slice(),
dummy_nonce.as_slice(),
dummy_tag.as_slice(),
],
)?;
None
};
Ok(carrier_node_id)
} }
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault). /// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - Keinerlei Klartext-Kennzeichnungen wie `vault_id` in SQLite
pub fn init_schema_with_hidden( pub fn init_schema_with_hidden(
&self, &self,
salt: &[u8; 16], salt: &[u8; 16],
@@ -235,7 +583,6 @@ impl Database {
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_id INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER, parent_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
is_dir INTEGER NOT NULL, is_dir INTEGER NOT NULL,
@@ -244,11 +591,10 @@ impl Database {
modified_at INTEGER NOT NULL, modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(vault_id, COALESCE(parent_id, 0), name); CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS chunks ( CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL, node_id INTEGER NOT NULL,
vault_id INTEGER NOT NULL DEFAULT 0,
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
tag BLOB NOT NULL, tag BLOB NOT NULL,
@@ -274,11 +620,16 @@ impl Database {
], ],
)?; )?;
// Wurzelknoten '/' für Vault 0 (id = 1, vault_id = 0) anlegen // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
let now = current_timestamp(); let now = current_timestamp();
conn.execute( conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at) "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)", VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, NULL, '', 1, 0, ?1, ?2)",
params![now, now], params![now, now],
)?; )?;
@@ -298,13 +649,6 @@ impl Database {
h_tag.as_slice(), h_tag.as_slice(),
], ],
)?; )?;
// Wurzelknoten '/' für Hidden Vault (id = 2, vault_id = 1) anlegen
conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, 1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
} else { } else {
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie // Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
@@ -509,7 +853,7 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = 0 AND parent_id = ?1 AND name = ?2", WHERE parent_id = ?1 AND name = ?2",
)?; )?;
let record: Option<NodeRecord> = stmt let record: Option<NodeRecord> = stmt
@@ -541,7 +885,7 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = 1 AND parent_id = ?1", WHERE parent_id = ?1",
)?; )?;
let rows = stmt.query_map(params![current_id], |row| { let rows = stmt.query_map(params![current_id], |row| {
@@ -559,7 +903,7 @@ impl Database {
let mut matched_record = None; let mut matched_record = None;
for r in rows { for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name = decrypt_node_name(dek, &enc_name).unwrap_or(enc_name); let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
if dec_name == *segment { if dec_name == *segment {
matched_record = Some(NodeRecord { matched_record = Some(NodeRecord {
id, id,
@@ -604,14 +948,15 @@ impl Database {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes WHERE id = ?1 AND vault_id = ?2", FROM nodes WHERE id = ?1",
)?; )?;
let record = stmt let record = stmt
.query_row(params![id, vault_id], |row| { .query_row(params![id], |row| {
let enc_name: String = row.get(2)?; let enc_name: String = row.get(2)?;
let p_id: Option<i64> = row.get(1)?;
let name = if vault_id == 1 { let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
} else { } else {
enc_name enc_name
}; };
@@ -646,14 +991,14 @@ impl Database {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at "SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes FROM nodes
WHERE vault_id = ?1 AND parent_id = ?2 WHERE parent_id = ?1
ORDER BY is_dir DESC, id ASC", ORDER BY is_dir DESC, id ASC",
)?; )?;
let rows = stmt.query_map(params![vault_id, parent_id], |row| { let rows = stmt.query_map(params![parent_id], |row| {
let enc_name: String = row.get(2)?; let enc_name: String = row.get(2)?;
let name = if vault_id == 1 { let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name) decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
} else { } else {
enc_name enc_name
}; };
@@ -694,15 +1039,15 @@ impl Database {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 { let stored_name = if vault_id == 1 {
encrypt_node_name(dek, name) encrypt_node_name(dek, parent_id, name)
} else { } else {
name.to_string() name.to_string()
}; };
conn.execute( conn.execute(
"INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at) "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now], params![parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
)?; )?;
let new_id = conn.last_insert_rowid(); let new_id = conn.last_insert_rowid();
@@ -776,7 +1121,7 @@ impl Database {
let now = current_timestamp(); let now = current_timestamp();
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 { let stored_name = if vault_id == 1 {
encrypt_node_name(dek, new_name) encrypt_node_name(dek, new_parent_id, new_name)
} else { } else {
new_name.to_string() new_name.to_string()
}; };
@@ -855,6 +1200,43 @@ impl Database {
Ok(()) Ok(())
} }
/// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar
/// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01).
pub fn write_chunk_and_update_size(
&self,
node_id: i64,
chunk_index: u32,
nonce: &[u8; 12],
tag: &[u8; 16],
ciphertext: &[u8],
new_size: u64,
modified_at: u64,
) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
nonce = excluded.nonce,
tag = excluded.tag,
ciphertext = excluded.ciphertext",
params![
node_id,
chunk_index,
nonce.as_slice(),
tag.as_slice(),
ciphertext,
],
)?;
tx.execute(
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
params![new_size as i64, modified_at as i64, node_id],
)?;
tx.commit()?;
Ok(())
}
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei) /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
@@ -904,7 +1286,66 @@ impl Database {
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> { pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; let _res: (i64, i64, i64) = conn.query_row(
"PRAGMA wal_checkpoint(TRUNCATE);",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
Ok(())
}
/// Erstellt ein konsistentes Online-Live-Backup der gesamten Container-Datenbank via SQLite Online Backup API.
/// Kann auch während eines aktiven WebDAV-Mounts ohne Lese-/Schreibkonflikte ausgeführt werden.
pub fn online_backup<P: AsRef<Path>>(&self, dest_path: P) -> Result<()> {
let dest_path = dest_path.as_ref();
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut dest_conn = Connection::open(dest_path)?;
let src_conn = self.conn.lock().unwrap();
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
drop(backup);
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
/// Stellt einen Container vollständig aus einer Sicherungskopie wieder her und verifiziert die Konsistenz.
pub fn restore_from_backup<P: AsRef<Path>>(backup_path: P, dest_path: P) -> Result<()> {
let backup_path = backup_path.as_ref();
let dest_path = dest_path.as_ref();
if !backup_path.exists() {
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let src_conn = Connection::open(backup_path)?;
let mut dest_conn = Connection::open(dest_path)?;
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
drop(backup);
drop(src_conn);
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
// B-Tree Integritätsprüfung
let check: String = dest_conn.query_row("PRAGMA quick_check;", [], |r| r.get(0))?;
if check != "ok" {
bail!("Integritätsprüfung des wiederhergestellten Containers fehlgeschlagen: {check}");
}
Ok(()) Ok(())
} }
@@ -927,8 +1368,12 @@ impl Database {
conn.execute("DELETE FROM meta", [])?; conn.execute("DELETE FROM meta", [])?;
let mut has_slot1 = false;
if !meta.slots.is_empty() { if !meta.slots.is_empty() {
for slot in &meta.slots { for slot in &meta.slots {
if slot.slot_id == 1 {
has_slot1 = true;
}
let params_json = serde_json::to_string(&slot.kdf_params)?; let params_json = serde_json::to_string(&slot.kdf_params)?;
conn.execute( conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
@@ -960,8 +1405,10 @@ impl Database {
meta.header_tag.as_slice(), meta.header_tag.as_slice(),
], ],
)?; )?;
}
// Dummy-Slot 1 für Plausible Deniability generieren // Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
if !has_slot1 {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?; let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute( conn.execute(
@@ -1234,8 +1681,6 @@ mod tests {
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
).unwrap(); ).unwrap();
assert!(db.has_hidden_vault().unwrap());
// Slots prüfen // Slots prüfen
let slots = db.read_slots().unwrap(); let slots = db.read_slots().unwrap();
assert_eq!(slots.len(), 2); assert_eq!(slots.len(), 2);
@@ -1268,7 +1713,7 @@ mod tests {
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap(); let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!"); assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabelle `nodes` // Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
let conn = db.conn.lock().unwrap(); let conn = db.conn.lock().unwrap();
let raw_name_v0: String = conn.query_row( let raw_name_v0: String = conn.query_row(
"SELECT name FROM nodes WHERE id = ?1", "SELECT name FROM nodes WHERE id = ?1",
@@ -1282,7 +1727,67 @@ mod tests {
params![hidden_file.id], params![hidden_file.id],
|r| r.get(0), |r| r.get(0),
).unwrap(); ).unwrap();
assert!(raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname muss verschlüsselt mit $h$ beginnen"); // Plausible Deniability: Kein $h$-Präfix, kein Klartext
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
// Keine Spalte `vault_id` in nodes oder chunks
let has_vault_id_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
let has_vault_id_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
}
#[test]
fn test_atomic_chunk_write_and_size_update() {
let db = Database::open_in_memory().unwrap();
let (salt, kdf, wrapped_dek, nonce, tag) = (
[1u8; 16],
KdfParams::default(),
vec![2u8; 40],
[3u8; 12],
[4u8; 16],
);
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
let file = db.create_node(1, "crash_test.bin", false).unwrap();
assert_eq!(file.size, 0);
let chunk_nonce = [5u8; 12];
let chunk_tag = [6u8; 16];
let ciphertext = vec![7u8; 1024];
let new_size = 1024u64;
let modified_at = 2000000u64;
db.write_chunk_and_update_size(
file.id,
0,
&chunk_nonce,
&chunk_tag,
&ciphertext,
new_size,
modified_at,
).expect("Atomic write");
// Chunk verifizieren
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
assert_eq!(chunk.ciphertext, ciphertext);
assert_eq!(chunk.nonce, chunk_nonce);
assert_eq!(chunk.tag, chunk_tag);
// Inode verifizieren
let nodes = db.list_children(1).unwrap();
let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap();
assert_eq!(updated_file.size, new_size);
assert_eq!(updated_file.modified_at, modified_at);
} }
} }
+63 -5
View File
@@ -110,7 +110,7 @@ pub fn verify_container(
node_map.insert(node.id, node.clone()); node_map.insert(node.id, node.clone());
} }
// Root-Knoten prüfen (id = 1) // Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault)
match node_map.get(&1) { match node_map.get(&1) {
Some(root) => { Some(root) => {
if !root.is_dir { if !root.is_dir {
@@ -125,9 +125,18 @@ pub fn verify_container(
} }
} }
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes { for node in &all_nodes {
if node.id == 1 { if node.id == 1 || node.id == 2 {
continue; continue;
} }
@@ -174,6 +183,54 @@ pub fn verify_container(
} }
} }
// Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1)
let mut vault0_nodes = HashSet::new();
let mut vault1_nodes = HashSet::new();
vault0_nodes.insert(1i64);
if node_map.contains_key(&2) {
vault1_nodes.insert(2i64);
}
let mut changed = true;
while changed {
changed = false;
for node in &all_nodes {
if let Some(pid) = node.parent_id {
if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) {
vault0_nodes.insert(node.id);
changed = true;
} else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) {
vault1_nodes.insert(node.id);
changed = true;
}
}
}
}
// Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört
let active_vault_nodes = if let Some(active_dek) = dek {
let is_vault1 = {
let mut found_v1 = false;
for node in &all_nodes {
if node.parent_id == Some(2) {
if crate::crypto::decrypt_node_name(active_dek, 2, &node.name).is_some() {
found_v1 = true;
break;
}
}
}
found_v1
};
if is_vault1 {
Some(&vault1_nodes)
} else {
Some(&vault0_nodes)
}
} else {
None
};
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers() let chunk_headers = db.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?; .context("Fehler beim Abrufen der Chunk-Liste")?;
@@ -187,8 +244,9 @@ pub fn verify_container(
)); ));
} }
if let Some(active_dek) = dek { if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
if full_chunks { // Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault)
if target_nodes.contains(&node_id) && full_chunks {
match db.read_chunk(node_id, chunk_index) { match db.read_chunk(node_id, chunk_index) {
Ok(Some(record)) => { Ok(Some(record)) => {
match decrypt_chunk( match decrypt_chunk(
@@ -283,7 +341,7 @@ mod tests {
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1); assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 2); // Root + photos assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_chunks, 2); assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0); assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0); assert_eq!(report.orphan_nodes, 0);
+333 -43
View File
@@ -14,8 +14,9 @@ use dav_server::{
}; };
use futures_util::stream; use futures_util::stream;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use zeroize::Zeroizing; use zeroize::{Zeroize, Zeroizing};
use crate::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord}; use crate::storage::{Database, NodeRecord};
@@ -134,6 +135,7 @@ impl SanctumFile {
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32, format_version: u32,
last_activity: Arc<AtomicU64>, last_activity: Arc<AtomicU64>,
append: bool,
) -> Self { ) -> Self {
let meta = SanctumMetaData { let meta = SanctumMetaData {
is_dir: node.is_dir, is_dir: node.is_dir,
@@ -142,10 +144,12 @@ impl SanctumFile {
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
}; };
let cursor = if append { node.size } else { 0 };
Self { Self {
node_id: node.id, node_id: node.id,
file_size: node.size, file_size: node.size,
cursor: 0, cursor,
db, db,
dek, dek,
meta, meta,
@@ -164,8 +168,15 @@ impl SanctumFile {
} }
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
fn flush_cached_chunk(&mut self) -> Result<(), FsError> { /// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
fn flush_cached_chunk_and_size(&mut self) -> Result<(), FsError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some((idx, ref data, true)) = self.cached_chunk { if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) = let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
@@ -173,16 +184,35 @@ impl SanctumFile {
FsError::GeneralFailure FsError::GeneralFailure
})?; })?;
self.db if let Err(e) = self.db.write_chunk_and_update_size(
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext) self.node_id,
.map_err(|e| { idx,
error!("DB-Fehler beim Schreiben des Chunks: {e}"); &nonce,
FsError::GeneralFailure &tag,
})?; &ciphertext,
self.file_size,
now,
) {
error!("DB-Fehler beim atomaren Chunk- und Size-Write #{idx}: {e}");
// CHAOS-03: Bei I/O- oder Disk-Full-Fehlern den Cache sauber invalidieren,
// um Folgefehler und Panic-/Warnungsschleifen beim Drop zu unterbinden!
self.cached_chunk = None;
return Err(FsError::GeneralFailure);
}
if let Some((_, _, ref mut dirty)) = self.cached_chunk { if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = false; *dirty = false;
} }
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} else if self.meta.size != self.file_size {
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
return Err(FsError::GeneralFailure);
}
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} }
Ok(()) Ok(())
} }
@@ -195,7 +225,10 @@ impl SanctumFile {
}; };
if !is_current { if !is_current {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize();
}
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| { let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}"); error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
@@ -229,14 +262,12 @@ impl SanctumFile {
impl Drop for SanctumFile { impl Drop for SanctumFile {
fn drop(&mut self) { fn drop(&mut self) {
if let Err(e) = self.flush_cached_chunk() { if let Err(e) = self.flush_cached_chunk_and_size() {
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e); warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
} }
let now = SystemTime::now() if let Some((_, ref mut data, _)) = self.cached_chunk {
.duration_since(UNIX_EPOCH) data.zeroize();
.map(|d| d.as_secs()) }
.unwrap_or(0);
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
} }
} }
@@ -324,7 +355,7 @@ impl DavFile for SanctumFile {
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen // Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) { if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
} }
src = &src[to_write..]; src = &src[to_write..];
@@ -360,19 +391,7 @@ impl DavFile for SanctumFile {
fn flush(&mut self) -> FsFuture<'_, ()> { fn flush(&mut self) -> FsFuture<'_, ()> {
self.touch(); self.touch();
Box::pin(async move { Box::pin(async move {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.db
.update_node_size_and_time(self.node_id, self.file_size, now)
.map_err(|e| {
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
FsError::GeneralFailure
})?;
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
Ok(()) Ok(())
}) })
} }
@@ -386,6 +405,10 @@ impl DavFile for SanctumFile {
pub struct SanctumFs { pub struct SanctumFs {
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
#[allow(dead_code)]
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
carrier_node_id: Option<i64>,
carrier_fs: Option<CarrierFs>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
@@ -412,14 +435,71 @@ impl SanctumFs {
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
) -> Self {
Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id)
}
pub fn with_carrier(
db: Database,
dek: Zeroizing<[u8; 32]>,
carrier_dek: Option<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self { ) -> Self {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new);
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
let carrier_node_id = carrier_node_id.or_else(|| {
if vault_id == 0 {
db.find_carrier_node_id().ok().flatten()
} else {
None
}
});
let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
match CarrierFs::load(
db.clone(),
c_nid,
c_dek.clone(),
dek_arc.clone(),
format_version,
anti_leak,
) {
Ok(cfs) => Some(cfs),
Err(e) => {
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
None
}
}
} else {
None
}
} else {
None
};
// Forensischer RAM-Paging-Schutz via VirtualLock (verhindert Auslagerung in pagefile.sys)
crate::windows::lock_memory(dek_arc.as_ptr(), 32);
if let Some(ref c_dek) = carrier_dek_arc {
crate::windows::lock_memory(c_dek.as_ptr(), 32);
}
Self { Self {
db, db,
dek: Arc::new(dek), dek: dek_arc,
carrier_dek: carrier_dek_arc,
carrier_node_id,
carrier_fs,
format_version, format_version,
anti_leak, anti_leak,
vault_id, vault_id,
@@ -432,7 +512,11 @@ impl SanctumFs {
} }
pub fn last_activity(&self) -> Arc<AtomicU64> { pub fn last_activity(&self) -> Arc<AtomicU64> {
self.last_activity.clone() if let Some(ref cfs) = self.carrier_fs {
cfs.last_activity()
} else {
self.last_activity.clone()
}
} }
pub fn is_anti_leak_enabled(&self) -> bool { pub fn is_anti_leak_enabled(&self) -> bool {
@@ -440,11 +524,15 @@ impl SanctumFs {
} }
pub fn touch(&self) { pub fn touch(&self) {
let now = SystemTime::now() if let Some(ref cfs) = self.carrier_fs {
.duration_since(UNIX_EPOCH) cfs.touch();
.map(|d| d.as_secs()) } else {
.unwrap_or(0); let now = SystemTime::now()
self.last_activity.store(now, Ordering::Relaxed); .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_activity.store(now, Ordering::Relaxed);
}
} }
fn path_to_str(path: &DavPath) -> String { fn path_to_str(path: &DavPath) -> String {
@@ -460,6 +548,7 @@ impl SanctumFs {
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> { fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
validate_path_safety(path)?;
self.db self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek) .resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
@@ -472,6 +561,7 @@ impl SanctumFs {
} }
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> { fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
validate_path_safety(name)?;
self.db self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| { .map_err(|e| {
@@ -481,18 +571,45 @@ impl SanctumFs {
} }
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
validate_path_safety(new_name)?;
self.db self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
} }
} }
/// Validiert, dass ein Pfad oder Dateiname keine Null-Bytes oder unzulässige Steuerzeichen enthält (CHAOS-02).
pub fn validate_path_safety(path: &str) -> Result<(), FsError> {
if path.contains('\0') {
return Err(FsError::Forbidden);
}
for c in path.chars() {
if (c as u32) < 0x20 && c != '\t' {
return Err(FsError::Forbidden);
}
}
Ok(())
}
impl Drop for SanctumFs {
fn drop(&mut self) {
crate::windows::unlock_memory(self.dek.as_ptr(), 32);
if let Some(ref c_dek) = self.carrier_dek {
crate::windows::unlock_memory(c_dek.as_ptr(), 32);
}
}
}
impl DavFileSystem for SanctumFs { impl DavFileSystem for SanctumFs {
fn open<'a>( fn open<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
options: OpenOptions, options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> { ) -> FsFuture<'a, Box<dyn DavFile>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.open(path, options);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let (parent_path, file_name) = self.split_parent_and_name(&path_str); let (parent_path, file_name) = self.split_parent_and_name(&path_str);
@@ -520,6 +637,11 @@ impl DavFileSystem for SanctumFs {
let node = match existing_node { let node = match existing_node {
Some(n) => { Some(n) => {
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
return Err(FsError::Forbidden);
}
if n.is_dir && (options.write || options.append) { if n.is_dir && (options.write || options.append) {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -571,6 +693,7 @@ impl DavFileSystem for SanctumFs {
self.dek.clone(), self.dek.clone(),
self.format_version, self.format_version,
self.last_activity.clone(), self.last_activity.clone(),
options.append,
); );
Ok(Box::new(file) as Box<dyn DavFile>) Ok(Box::new(file) as Box<dyn DavFile>)
}) })
@@ -579,10 +702,13 @@ impl DavFileSystem for SanctumFs {
fn read_dir<'a>( fn read_dir<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
_meta: ReadDirMeta, meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.read_dir(path, meta);
}
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
@@ -615,11 +741,12 @@ impl DavFileSystem for SanctumFs {
} }
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.metadata(path);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -636,10 +763,17 @@ impl DavFileSystem for SanctumFs {
} }
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.symlink_metadata(path);
}
self.metadata(path) self.metadata(path)
} }
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.create_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -668,6 +802,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -684,6 +822,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
if let Some(carrier_id) = self.carrier_node_id {
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
return Err(FsError::Forbidden);
}
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -693,6 +838,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_file(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -704,6 +853,11 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Löschen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -717,6 +871,10 @@ impl DavFileSystem for SanctumFs {
from: &'a DavPath, from: &'a DavPath,
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
@@ -726,6 +884,11 @@ impl DavFileSystem for SanctumFs {
.resolve_path(&from_str)? .resolve_path(&from_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) { if self.anti_leak && is_leak_file(to_name) {
@@ -745,6 +908,10 @@ impl DavFileSystem for SanctumFs {
if dest.is_dir { if dest.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten!
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(dest.id) .delete_node(dest.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -780,6 +947,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
if let Some(dest) = self.resolve_path(&to_str)? {
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
}
let to_parent = self let to_parent = self
.resolve_path(to_parent_path)? .resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -1003,4 +1177,120 @@ mod tests {
let new_time = act_arc.load(Ordering::Relaxed); let new_time = act_arc.load(Ordering::Relaxed);
assert!(new_time > 1000); assert!(new_time > 1000);
} }
#[tokio::test]
async fn test_carrier_protection_in_decoy_vault() {
let (fs, _dir) = create_test_fs(true);
// Erstelle eine Carrier-Datei "system_backup.dat"
let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap();
let carrier_id = carrier_node.id;
// Erstelle FS mit bekanntem carrier_node_id
let protected_fs = SanctumFs::with_carrier(
fs.db.clone(),
(*fs.dek).clone(),
None,
Some(carrier_id),
FORMAT_VERSION,
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// 1. Lesen muss erlaubt sein
let mut read_opts = OpenOptions::default();
read_opts.read = true;
assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok());
// 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden)
let mut write_opts = OpenOptions::default();
write_opts.write = true;
assert!(matches!(
protected_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
));
let mut trunc_opts = OpenOptions::default();
trunc_opts.truncate = true;
assert!(matches!(
protected_fs.open(&carrier_path, trunc_opts).await,
Err(FsError::Forbidden)
));
// 3. Löschen der Carrier-Datei muss verboten sein
assert!(matches!(
protected_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
));
// 4. Umbenennen der Carrier-Datei muss verboten sein
let rename_target = DavPath::new("/renamed_backup.dat").unwrap();
assert!(matches!(
protected_fs.rename(&carrier_path, &rename_target).await,
Err(FsError::Forbidden)
));
// 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein
let other_path = DavPath::new("/other.txt").unwrap();
let mut other_opts = OpenOptions::default();
other_opts.write = true;
other_opts.create_new = true;
protected_fs.open(&other_path, other_opts).await.unwrap();
assert!(matches!(
protected_fs.rename(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
// 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein
assert!(matches!(
protected_fs.copy(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
}
#[tokio::test]
async fn test_path_safety_rejects_null_bytes_and_control_chars() {
let (fs, _dir) = create_test_fs(true);
// 1. Null-Byte im Pfad
let null_path = DavPath::new("/bad\0file.txt");
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
assert!(validate_path_safety("/bad\0file.txt").is_err());
// 2. Steuerzeichen < 0x20
assert!(validate_path_safety("/bad\x01file.txt").is_err());
assert!(validate_path_safety("/bad\rfile.txt").is_err());
assert!(validate_path_safety("/bad\nfile.txt").is_err());
// 3. Gültiger Pfad
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
assert!(validate_path_safety("/path/to/subfolder/file.pdf").is_ok());
}
#[tokio::test]
async fn test_write_atomic_and_cache_invalidation() {
let (fs, _dir) = create_test_fs(true);
let path = DavPath::new("/atomic_test.bin").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut file = fs.open(&path, opts).await.unwrap();
// 1. Schreibe 500 Bytes
let payload = Bytes::from(vec![42u8; 500]);
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
file.flush().await.unwrap();
let node = fs.resolve_path("/atomic_test.bin").unwrap().unwrap();
assert_eq!(node.size, 500);
// Chunk in DB prüfen
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
assert!(!chunk.ciphertext.is_empty());
}
} }
+372 -93
View File
@@ -1,5 +1,6 @@
use std::process::Command; use std::process::Command;
#[allow(unused_imports)]
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D'). /// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
@@ -32,14 +33,68 @@ pub fn find_next_available_drive() -> Result<char> {
} }
} }
/// Öffnet das eingebundene Netzlaufwerk direkt im Windows Explorer. /// Öffnet das eingebundene Netzlaufwerk oder Verzeichnis direkt im systemeigenen Dateimanager
/// (Windows: Explorer, macOS: open, Linux: xdg-open).
pub fn open_in_explorer(drive_char: char) -> Result<()> { pub fn open_in_explorer(drive_char: char) -> Result<()> {
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase()); #[cfg(windows)]
Command::new("explorer.exe") {
.arg(&drive_path) let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
.spawn() Command::new("explorer.exe")
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?; .arg(&drive_path)
Ok(()) .spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
let _ = drive_char;
let _ = Command::new("open").arg(".").spawn();
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = drive_char;
let _ = Command::new("xdg-open").arg(".").spawn();
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = drive_char;
Ok(())
}
}
/// Öffnet einen beliebigen Pfad im nativen Dateimanager der Plattform.
pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
#[cfg(windows)]
{
Command::new("explorer.exe")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Command::new("xdg-open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = path;
Ok(())
}
} }
/// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED). /// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED).
@@ -67,104 +122,146 @@ pub fn notify_shell_associations_changed() {
); );
} }
} }
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = Command::new("update-desktop-database").spawn();
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
}
} }
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer (HKCU, 100% Userland, keine Adminrechte). /// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
pub fn register_explorer_integration() -> Result<()> { pub fn register_explorer_integration() -> Result<()> {
let current_exe = std::env::current_exe() #[cfg(windows)]
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?; {
let exe_str = current_exe.display().to_string(); let current_exe = std::env::current_exe()
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
let exe_str = current_exe.display().to_string();
let reg_commands = [ let reg_commands = [
// 1. .sanctum Erweiterung mit ProgID verknüpfen // 1. .sanctum Erweiterung mit ProgID verknüpfen
( (
r"HKCU\Software\Classes\.sanctum", r"HKCU\Software\Classes\.sanctum",
"", "",
"Sanctum.Container", "Sanctum.Container",
), ),
// 2. ProgID Metadaten & Beschreibung // 2. ProgID Metadaten & Beschreibung
( (
r"HKCU\Software\Classes\Sanctum.Container", r"HKCU\Software\Classes\Sanctum.Container",
"", "",
"Sanctum Verschlüsselter Container", "Sanctum Verschlüsselter Container",
), ),
// 3. Icon // 3. Icon
( (
r"HKCU\Software\Classes\Sanctum.Container\DefaultIcon", r"HKCU\Software\Classes\Sanctum.Container\DefaultIcon",
"", "",
&format!("\"{exe_str}\",0"), &format!("\"{exe_str}\",0"),
), ),
// 4. Standard-Doppelklick-Aktion: Mount // 4. Standard-Doppelklick-Aktion: Mount
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\open", r"HKCU\Software\Classes\Sanctum.Container\shell\open",
"", "",
"Als Laufwerk einbinden", "In Sanctum öffnen",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\open\command", r"HKCU\Software\Classes\Sanctum.Container\shell\open\command",
"", "",
&format!("\"{exe_str}\" mount --path \"%1\""), &format!("\"{exe_str}\" mount \"%1\""),
), ),
// 5. Kontextmenü-Aktion: Integritätsprüfung // 5. Kontextmenü-Aktion: Verify / FSCK
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\verify", r"HKCU\Software\Classes\Sanctum.Container\shell\verify",
"", "",
"Integrität prüfen (FSCK)", "Integrität prüfen (FSCK)",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command", r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command",
"", "",
&format!("\"{exe_str}\" verify --path \"%1\""), &format!("cmd /k \"\"{exe_str}\" verify \"%1\"\""),
), ),
// 6. Kontextmenü-Aktion: Header sichern // 6. Kontextmenü-Aktion: Header-Backup
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header", r"HKCU\Software\Classes\Sanctum.Container\shell\backup",
"", "",
"Header sichern", "Header sichern (Disaster Recovery)",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header\command", r"HKCU\Software\Classes\Sanctum.Container\shell\backup\command",
"", "",
&format!("\"{exe_str}\" backup-header --path \"%1\""), &format!("cmd /k \"\"{exe_str}\" backup-header \"%1\"\""),
), ),
]; ];
for (key, val_name, val_data) in reg_commands { for (key, val_name, val_data) in reg_commands {
let mut cmd = Command::new("reg"); let mut cmd = Command::new("reg");
cmd.arg("add").arg(key); cmd.arg("add").arg(key);
if val_name.is_empty() { if val_name.is_empty() {
cmd.arg("/ve"); cmd.arg("/ve");
} else { } else {
cmd.arg("/v").arg(val_name); cmd.arg("/v").arg(val_name);
}
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
}
} }
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?; notify_shell_associations_changed();
if !output.status.success() { Ok(())
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
}
} }
#[cfg(not(windows))]
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let apps_dir = home_path.join(".local/share/applications");
let mime_dir = home_path.join(".local/share/mime/packages");
let _ = std::fs::create_dir_all(&apps_dir);
let _ = std::fs::create_dir_all(&mime_dir);
notify_shell_associations_changed(); let desktop_content = "[Desktop Entry]\nType=Application\nName=Sanctum\nComment=Verschlüsselter Ein-Datei-Container\nExec=sanctum mount %f\nIcon=security-high\nTerminal=true\nMimeType=application/x-sanctum;\nCategories=Utility;Security;\n";
Ok(()) let _ = std::fs::write(apps_dir.join("sanctum.desktop"), desktop_content);
let mime_content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n <mime-type type=\"application/x-sanctum\">\n <comment>Sanctum Verschlüsselter Container</comment>\n <glob pattern=\"*.sanctum\"/>\n </mime-type>\n</mime-info>\n";
let _ = std::fs::write(mime_dir.join("application-x-sanctum.xml"), mime_content);
notify_shell_associations_changed();
}
Ok(())
}
} }
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU). /// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU) bzw. unter Linux aus Freedesktop.
pub fn unregister_explorer_integration() -> Result<()> { pub fn unregister_explorer_integration() -> Result<()> {
let keys_to_delete = [ #[cfg(windows)]
r"HKCU\Software\Classes\.sanctum", {
r"HKCU\Software\Classes\Sanctum.Container", let keys_to_delete = [
]; r"HKCU\Software\Classes\.sanctum",
r"HKCU\Software\Classes\Sanctum.Container",
];
for key in keys_to_delete { for key in keys_to_delete {
let _ = Command::new("reg") let _ = Command::new("reg")
.args(["delete", key, "/f"]) .args(["delete", key, "/f"])
.output(); .output();
}
notify_shell_associations_changed();
Ok(())
}
#[cfg(not(windows))]
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
notify_shell_associations_changed();
}
Ok(())
} }
notify_shell_associations_changed();
Ok(())
} }
/// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray. /// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray.
@@ -198,10 +295,9 @@ pub fn get_default_system_icon() -> Option<tray_item::IconSource> {
/// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre. /// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre.
/// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet. /// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet.
#[cfg(windows)]
pub struct SessionLockGuard { pub struct SessionLockGuard {
#[cfg(windows)]
hwnd: isize, hwnd: isize,
#[cfg(windows)]
join_handle: Option<std::thread::JoinHandle<()>>, join_handle: Option<std::thread::JoinHandle<()>>,
} }
@@ -419,10 +515,184 @@ 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 unter Windows bzw. Swap unter Linux/macOS).
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(unix)]
{
extern "C" {
fn mlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { mlock(ptr as *const std::ffi::c_void, len) == 0 }
}
#[cfg(not(any(windows, unix)))]
{
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(unix)]
{
extern "C" {
fn munlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { munlock(ptr as *const std::ffi::c_void, len) == 0 }
}
#[cfg(not(any(windows, unix)))]
{
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 +708,13 @@ mod tests {
// Dropping monitor closes message loop and joins thread cleanly // Dropping monitor closes message loop and joins thread cleanly
drop(monitor); drop(monitor);
} }
#[test]
fn test_console_ctrl_monitor_lifecycle() {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let monitor = start_console_ctrl_monitor(tx, 'Z');
assert!(monitor.is_ok());
drop(monitor);
}
} }
+472
View File
@@ -0,0 +1,472 @@
use std::path::PathBuf;
use bytes::Bytes;
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use sanctum::vfs::SanctumFs;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id));
path
}
#[tokio::test]
async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let path = temp_db_path("carrier_accounting");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB)
let carrier_name = "system_backup.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
let created_cid = db
.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
assert_eq!(created_cid, Some(carrier_node_id));
// 1. Authentifizierung beider Passwörter
let meta = db.read_meta().unwrap();
let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.slot_id(), 0);
assert_eq!(**auth_decoy.dek(), *dek_0);
assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id));
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.slot_id(), 1);
assert_eq!(**auth_hidden.dek(), *dek_1);
assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0);
assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id));
// 2. Decoy Mount: Schutz der Alibi-Datei (system_backup.dat)
let decoy_fs = SanctumFs::with_carrier(
db.clone(),
auth_decoy.dek().clone(),
auth_decoy.carrier_dek(),
auth_decoy.carrier_node_id(),
auth_decoy.version(),
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// Metadaten der Alibi-Datei im Decoy prüfen
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
assert_eq!(carrier_meta.len(), carrier_size_bytes);
assert!(!carrier_meta.is_dir());
// Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden
let write_opts = OpenOptions {
write: true,
..Default::default()
};
assert!(
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
assert!(
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht gelöscht werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
let new_name = DavPath::new("/renamed.iso").unwrap();
assert!(
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht umbenannt werden"
);
// Alibi-Datei KANN im Decoy-Mount gelesen werden
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
assert_eq!(first_mb.len(), CHUNK_SIZE);
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer)
let root_path = DavPath::new("/").unwrap();
let mut stream = hidden_fs
.read_dir(&root_path, ReadDirMeta::None)
.await
.expect("Read dir root");
use futures_util::StreamExt;
let mut entries = Vec::new();
while let Some(item) = stream.next().await {
entries.push(item.unwrap().name());
}
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
// Ordner erstellen
let secret_dir = DavPath::new("/Classified").unwrap();
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
// Datei im Ordner anlegen und schreiben
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
let create_opts = OpenOptions {
create: true,
write: true,
..Default::default()
};
let mut secret_file = hidden_fs
.open(&secret_file_path, create_opts)
.await
.expect("Create secret file");
let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault";
secret_file
.write_bytes(Bytes::from_static(secret_content))
.await
.expect("Write secret content");
secret_file.flush().await.expect("Flush secret file");
drop(secret_file);
// Datei lesen und verifizieren
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut read_handle = hidden_fs
.open(&secret_file_path, read_opts)
.await
.expect("Open secret file for read");
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
assert_eq!(&read_data[..], secret_content);
drop(read_handle);
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
let mut big_file = hidden_fs
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Create big file");
let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes
let mut payload = vec![0u8; payload_size];
OsRng.fill_bytes(&mut payload);
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
big_file.flush().await.expect("Flush big file");
drop(big_file);
// Datei zurücklesen und Bit-für-Bit verifizieren
let mut read_big = hidden_fs
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open big file");
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
assert_eq!(read_big_bytes.len(), payload_size);
assert_eq!(&read_big_bytes[..], &payload[..]);
drop(read_big);
// Datei umbenennen
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
// Datei löschen (Blöcke werden geshreddert und freigegeben)
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
// Checkpoint SQLite
db.checkpoint().unwrap();
// 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG
// Ein Angreifer besitzt nur das Decoy-Passwort (dek_0).
// Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch.
// ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln!
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
assert!(
report.is_healthy(),
"Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}",
report.errors
);
assert_eq!(
report.corrupted_chunks, 0,
"Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!"
);
assert_eq!(
report.orphan_nodes, 0,
"Es darf keine verwaisten Knoten geben!"
);
// Aufräumen
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_model_a_container_file_size_invariance() {
let path = temp_db_path("carrier_size_invariance");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"virtual_disk.vhdx",
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
// Initiale Dateigröße messen
let initial_file_size = std::fs::metadata(&path).unwrap().len();
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
let mut handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file");
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
OsRng.fill_bytes(&mut random_data);
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
handle.flush().await.expect("Flush 4MB");
drop(handle);
db.checkpoint().unwrap();
// Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen
let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len();
// Die Dateigröße auf der Festplatte DARF NICHT WACHSEN!
// Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben.
assert_eq!(
initial_file_size, size_after_hidden_writes,
"Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}",
initial_file_size, size_after_hidden_writes
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_carrier_file_drop_and_append_mode() {
let path = temp_db_path("carrier_drop_append");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let carrier_name = "test_carrier.iso";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "HiddenSecretPassword2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
let mut write_handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file for write");
let initial_data = b"Hello from unflushed write!";
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
// WICHTIG: KEIN write_handle.flush()! Nur drop:
drop(write_handle);
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
let mut read_handle = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read");
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
drop(read_handle);
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
let append_data = b" - Appended data at EOF!";
let mut append_handle = hidden_fs
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
.await
.expect("Open file for append");
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
drop(append_handle); // Drop sichert auch hier
// Prüfe den vollständigen Dateiinhalt nach Append
let mut read_handle_2 = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read after append");
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
let mut expected = Vec::new();
expected.extend_from_slice(initial_data);
expected.extend_from_slice(append_data);
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
drop(read_handle_2);
let _ = std::fs::remove_file(&path);
}
+371
View File
@@ -792,6 +792,377 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
let _ = std::fs::remove_file(&container_path); let _ = std::fs::remove_file(&container_path);
} }
#[tokio::test]
async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() {
let temp_dir = std::env::temp_dir();
let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id()));
let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id()));
if path_standard.exists() {
let _ = std::fs::remove_file(&path_standard);
}
if path_dual.exists() {
let _ = std::fs::remove_file(&path_dual);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let pass_decoy = "OuterDecoyPassphrase2026!";
let pass_hidden = "InnerHiddenVaultPass2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
// 1. Erstelle Standard-Container (Container A)
let salt_std = generate_salt();
let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap();
let dek_std = generate_dek();
let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap();
let db_std = Database::open(&path_standard).unwrap();
db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap();
db_std.checkpoint().unwrap();
// 2. Erstelle Dual-Vault Container (Container B)
let salt_b0 = generate_salt();
let salt_b1 = generate_salt();
let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap();
let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap();
let dek_b0 = generate_dek();
let dek_b1 = generate_dek();
let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap();
let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap();
let db_dual = Database::open(&path_dual).unwrap();
db_dual.init_schema_with_hidden(
&salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0,
Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)),
).unwrap();
db_dual.checkpoint().unwrap();
// 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit
let conn_std = rusqlite::Connection::open(&path_standard).unwrap();
let conn_dual = rusqlite::Connection::open(&path_dual).unwrap();
// A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern
for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] {
let v_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name);
let v_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name);
// Genau 2 Slots in meta
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap();
assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name);
// Genau 2 Root-Nodes in nodes (id=1 und id=2)
let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap();
assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name);
}
drop(conn_std);
drop(conn_dual);
// 4. Dateien in beiden Vaults von Container B anlegen
let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0);
let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1);
let decoy_path = DavPath::new("/family_recipe.txt").unwrap();
let hidden_path = DavPath::new("/private_journal.docx").unwrap();
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap();
f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap();
f_d.flush().await.unwrap();
drop(f_d);
let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap();
f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap();
f_h.flush().await.unwrap();
drop(f_h);
db_dual.checkpoint().unwrap();
// 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite
let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap();
let hidden_node_name: String = conn_dual2.query_row(
"SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0)
).unwrap();
assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!");
assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!");
assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein");
let count_dollar_h: i64 = conn_dual2.query_row(
"SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0)
).unwrap();
assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren");
drop(conn_dual2);
// 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG
let meta_dual = db_dual.read_meta().unwrap();
let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren");
assert_eq!(*auth_decoy.0, *dek_b0);
let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren");
assert_eq!(*auth_hidden.0, *dek_b1);
assert!(meta_dual.authenticate("WrongPassword123!").is_none());
// 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT
// Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden!
let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap();
assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors);
assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden");
assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden");
// Hidden-Prüfung: Ebenfalls gesund!
let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap();
assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors);
assert_eq!(report_hidden.corrupted_chunks, 0);
assert_eq!(report_hidden.orphan_nodes, 0);
// 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten!
export_header_backup(&path_dual, &backup_path).unwrap();
// Zerstöre Header
let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap();
conn_wipe.execute("DELETE FROM meta", []).unwrap();
drop(conn_wipe);
// Stelle wieder her
restore_header_backup(&path_dual, &backup_path).unwrap();
// Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos!
let restored_meta = db_dual.read_meta().unwrap();
let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore");
assert_eq!(res_decoy.2, 0);
let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore");
assert_eq!(res_hidden.2, 1);
// 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört!
let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap();
let brand_new_decoy_pass = "BrandNewDecoyPassword2026!";
restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap();
let rescued_meta = db_dual.read_meta().unwrap();
// Neues Decoy Passwort entsperrt Slot 0
let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass");
assert_eq!(rescued_decoy.2, 0);
assert_eq!(*rescued_decoy.0, *dek_b0);
// Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!)
let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved");
assert_eq!(rescued_hidden.2, 1);
assert_eq!(*rescued_hidden.0, *dek_b1);
// Aufräumen
let _ = std::fs::remove_file(&path_standard);
let _ = std::fs::remove_file(&path_dual);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_sanctum_online_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_backup_src_{}.sanctum", std::process::id()));
let backup_path = temp_dir.join(format!("test_backup_out_{}.sanctum.bak", std::process::id()));
let restored_path = temp_dir.join(format!("test_backup_restored_{}.sanctum", std::process::id()));
for p in [&container_path, &backup_path, &restored_path] {
if p.exists() {
let _ = std::fs::remove_file(p);
}
}
let password = "BackupTestPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
// Datei schreiben
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/important.txt").unwrap();
let mut file = fs.open(&test_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")).await.unwrap();
file.flush().await.unwrap();
drop(file);
// 1. Online-Live-Backup erstellen
db.online_backup(&backup_path).expect("Online backup should succeed");
assert!(backup_path.exists(), "Backup-Datei muss existieren");
// 2. Original-Container verändern (neue Datei hinzufügen)
let extra_file = DavPath::new("/extra_after_backup.txt").unwrap();
let mut file2 = fs.open(&extra_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file2.write_bytes(Bytes::from_static(b"After Backup Data")).await.unwrap();
file2.flush().await.unwrap();
drop(file2);
// 3. Restore aus dem Backup in neuen Pfad
Database::restore_from_backup(&backup_path, &restored_path).expect("Restore should succeed");
assert!(restored_path.exists(), "Wiederhergestellter Container muss existieren");
// 4. Verifiziere den wiederhergestellten Container
let restored_db = Database::open(&restored_path).unwrap();
let meta = restored_db.read_meta().unwrap();
let auth = meta.authenticate(password).expect("Passwort muss den wiederhergestellten Container entsperren");
assert_eq!(*auth.0, *dek);
let restored_fs = SanctumFs::new(restored_db.clone(), auth.0, meta.version);
// /important.txt muss existieren und den korrekten Inhalt haben
let mut read_handle = restored_fs.open(&test_file, OpenOptions { read: true, ..Default::default() }).await.unwrap();
let content = read_handle.read_bytes(100).await.unwrap();
assert_eq!(&content[..], b"Sanctum Online Backup Test Data");
drop(read_handle);
// /extra_after_backup.txt darf im Backup-Zustand NICHT existieren
assert!(restored_fs.open(&extra_file, OpenOptions { read: true, ..Default::default() }).await.is_err());
// Integritätsprüfung (FSCK) auf wiederhergestelltem Container
let report = verify_container(&restored_path, Some(&dek), true).unwrap();
assert!(report.is_healthy());
for p in [&container_path, &backup_path, &restored_path] {
let _ = std::fs::remove_file(p);
}
}
#[tokio::test]
async fn test_webdav_loopback_session_token_and_host_validation() {
use dav_server::{fakels::FakeLs, DavHandler};
use sanctum::mount::{is_loopback_host, serve_webdav_loop};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
// 1. Host-Header Unit Validierung
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8443"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:8443"));
assert!(!is_loopback_host("attacker.com"));
assert!(!is_loopback_host("evil.attacker.com:8443"));
assert!(!is_loopback_host("192.168.1.100"));
assert!(!is_loopback_host("internal.corp"));
// 2. Container und WebDAV Setup
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_webdav_sec_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "WebDavSecPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let session_token = "deadbeefcafebabe0123456789abcdef";
let token_path_prefix = format!("/{}", session_token);
let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let server_handle = tokio::spawn(serve_webdav_loop(
listener,
dav_server,
token_path_prefix.clone(),
shutdown_rx,
));
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 4. Angriffstest B: DNS-Rebinding mit fremdem Host-Header trotz erratenem Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET /{}/ HTTP/1.1\r\nHost: evil.attacker.com:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage mit fremdem Host-Header (DNS Rebinding) muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 200 OK"),
"Legitime OPTIONS-Anfrage mit Session-Token muss 200 OK liefern, erhalten:\n{}",
resp_str
);
assert!(
resp_str.to_lowercase().contains("dav:"),
"Antwort muss WebDAV DAV-Header enthalten"
);
}
// Server ordnungsgemäß beenden & aufräumen
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
let _ = std::fs::remove_file(&container_path);
}
+152
View File
@@ -0,0 +1,152 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use dav_server::{
davpath::DavPath,
fs::{DavFileSystem, OpenOptions},
};
use rand::RngCore;
use sanctum::{
crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
};
/// Live-Crash- und Stresstest: Simuliert harten Verbindungsabbruch und Power-Cut
/// während intensiver paralleler Schreibvorgänge im VFS.
#[tokio::test]
async fn test_live_crash_and_recovery_stress() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "LiveStressPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("DEK wrapping");
// 1. Initialisierung des Containers
{
let db = Database::open(&container_path).expect("Open database");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.expect("Init schema");
db.checkpoint().expect("Initial Checkpoint");
}
// 2. Parallele Schreiblast mit SanctumFs erzeugen
let stop_signal = Arc::new(AtomicBool::new(false));
let db = Database::open(&container_path).expect("Open database for VFS");
let fs = SanctumFs::new(db, dek.clone(), FORMAT_VERSION);
let mut handles = Vec::new();
// Spawn 4 parallele Schreiber
for worker_id in 0..4 {
let fs_clone = fs.clone();
let stop_clone = stop_signal.clone();
let handle = tokio::spawn(async move {
let mut file_idx = 0;
while !stop_clone.load(Ordering::Relaxed) && file_idx < 10 {
let file_path_str = format!("/worker_{}_file_{}.dat", worker_id, file_idx);
let dav_path = DavPath::new(&file_path_str).unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create = true;
opts.truncate = true;
// Datei anlegen
let mut file = match fs_clone.open(&dav_path, opts).await {
Ok(f) => f,
Err(_) => break,
};
// Mehrere 256-KB Blöcke schreiben (über mehrere Chunks hinweg)
let mut payload = vec![0u8; 256 * 1024];
rand::thread_rng().fill_bytes(&mut payload);
for _ in 0..6 {
if stop_clone.load(Ordering::Relaxed) {
break;
}
let _ = file.write_bytes(Bytes::copy_from_slice(&payload)).await;
}
let _ = file.flush().await;
file_idx += 1;
}
});
handles.push(handle);
}
// Lass die Worker 500ms unter Volllast schreiben
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// 3. Simuliere abrupten Prozessabbruch (Hard Kill / Power Cut)
// Wir brechen die Worker hart ab (Cancel) und verwerfen das FS-Handle ohne sauberen Unmount
stop_signal.store(true, Ordering::SeqCst);
for h in handles {
h.abort(); // Simuliert Kill
}
// FS ohne Checkpoint/Drop-Finalisierung freigeben
drop(fs);
// 4. Recovery & Integritätsprüfung nach Crash
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
assert!(
verify_result.is_healthy(),
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
verify_result.errors
);
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
// 5. Konsistentes Weiterarbeiten nach dem Absturz
let db_recovered = Database::open(&container_path).expect("Open database after crash");
let fs_recovered = SanctumFs::new(db_recovered, dek.clone(), FORMAT_VERSION);
// Neue Datei im wiederhergestellten Dateisystem anlegen und lesen
let recovery_test_path = DavPath::new("/post_crash_verification.txt").unwrap();
{
let mut opts = OpenOptions::default();
opts.write = true;
opts.create = true;
opts.truncate = true;
let mut file = fs_recovered
.open(&recovery_test_path, opts)
.await
.expect("Create post-crash file");
file.write_bytes(Bytes::from_static(b"Sanctum Crash Consistency Verified!"))
.await
.expect("Write post crash file");
file.flush().await.expect("Flush post crash file");
}
// Datei wieder einlesen
{
let mut opts = OpenOptions::default();
opts.read = true;
let mut file = fs_recovered
.open(&recovery_test_path, opts)
.await
.expect("Read post-crash file");
let bytes = file.read_bytes(1024).await.expect("Read bytes");
assert_eq!(&bytes[..], b"Sanctum Crash Consistency Verified!");
}
// Sauberes Aufräumen der Testdatei
drop(fs_recovered);
let _ = std::fs::remove_file(&container_path);
}