feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
This commit is contained in:
@@ -6,3 +6,5 @@
|
|||||||
.env.*
|
.env.*
|
||||||
*.token
|
*.token
|
||||||
.token
|
.token
|
||||||
|
*.key
|
||||||
|
sanctum-release.key
|
||||||
|
|||||||
@@ -5,6 +5,51 @@ 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.7.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Security Hardening Release
|
||||||
|
Dieses Release behebt alle 11 identifizierten Sicherheitsprobleme (S-01 bis S-11) aus dem Sicherheits-Review von v0.6.0 umfassend und implementiert ein striktes Fail-Closed-Design.
|
||||||
|
|
||||||
|
#### Security & Architecture
|
||||||
|
- **S-01: Ehrliche Neuklassifizierung des Dual-Vault (Second Safe)**:
|
||||||
|
- Beseitigung aller unhaltbaren Behauptungen über "Plausible Deniability", "RIPA-49-Konformität" und "mathematische Ununterscheidbarkeit".
|
||||||
|
- Klarstellung in Dokumentation und CLI: Das Dual-Vault-Design (Modell A mit Alibi-Carrier) bietet Schutz vor Schulterblick und erzwungener Herausgabe im Alltag, ist jedoch durch forensische Entropieanalyse der Trägerdatei detektierbar und stellt keinen rechtlichen Schutz gegen Beschlagnahme dar.
|
||||||
|
- **S-02: Minisign-Signaturprüfung & Härtung des Update-Mechanismus**:
|
||||||
|
- Integration von `minisign-verify` zur kryptografischen Prüfung der `SHA256SUMS.txt.minisig` gegen den offiziellen Ed25519 Release-Schlüssel (`RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU`) vor jedem Binary-Austausch.
|
||||||
|
- Sichere Erstellung von Zwischendateien mittels `tempfile::Builder` mit restriktiven Dateirechten und RAII-Bereinigung im Fehlerfall.
|
||||||
|
- Sperrung von Drittanbieter-URLs: Inoffizielle Server erfordern zwingend das Flag `--insecure-url` und eine interaktive Benutzerbestätigung (nicht durch `--yes` umgehbar).
|
||||||
|
- **S-03: Carrier-Schutz vor Beschädigung und Korruption**:
|
||||||
|
- Einführung der Datenbankspalte `is_carrier INTEGER NOT NULL DEFAULT 0` in der `nodes`-Tabelle zur präzisen Identifikation der Alibi-Trägerdatei ohne False Positives auf Benutzerdateien.
|
||||||
|
- Fail-Closed Schutzschranke `assert_not_carrier`: Löschung (`delete_node`), Kürzung (`truncate_chunks_after`), Umbenennung (`rename_node_in_vault`) oder VFS-Überschreibung der Trägerdatei sowie das Löschen enthaltender Ordner wird strikt verboten.
|
||||||
|
- Schutz der Trägerdatei bei `sanctum sync`: Die Alibi-Datei wird bei `--delete` (`delete_orphans_in_vault`) niemals entfernt.
|
||||||
|
- **S-04: Schutz vor nicht vertrauenswürdigen Containern & KDF-DoS**:
|
||||||
|
- `Database::open` prüft vor Ausführung von Pragmas, VACUUM oder Schema-Upgrades, ob fremde SQLite-Datenbanken vorliegen und lehnt diese sofort fail-closed ab.
|
||||||
|
- Strikte Bereichsprüfung aller KDF-Parameter (`validate_kdf_params`): Memory Cost 19 MiB bis 256 MiB, Time Cost 2 bis 8, Parallelität 1 bis 8.
|
||||||
|
- Strikte Validierung aller Header-Slot-Feldlängen (Salt 16B, Nonce 12B, Tag 16B, Wrapped DEK 40B/32B in Slot 0, 72B/64B/32B in Slot 1).
|
||||||
|
- **S-05: Eliminierung unkontrollierter Panics in der Kryptografie**:
|
||||||
|
- Beseitigung aller potenziellen Panics in `encrypt_node_name` und `decrypt_chunk` (LZ4-Größenvalidierung und unkomprimierte Chunks geben nun kontrollierte `Result`-Fehler zurück).
|
||||||
|
- Umstellung des Release-Profils in `Cargo.toml` auf `panic = "unwind"` zur Vermeidung von Process-Aborts bei unerwarteten Fehlern.
|
||||||
|
- **S-06: WebDAV-Loopback-Härtung & Unmount-Bereinigung**:
|
||||||
|
- Robuste Host-Header-Prüfung in `is_loopback_host` (exakter Abgleich gegen `127.0.0.1`, `localhost`, `[::1]`, `::1` samt Port-Validierung) zur Abwehr von DNS-Rebinding- und Host-Header-Injection-Angriffen.
|
||||||
|
- Beseitigung der Session-Token-Ausgabe im Terminal.
|
||||||
|
- Zwingende Überprüfung und Löschung von temporären SQLite `-wal` und `-shm` Dateien nach Aushängen des Containers.
|
||||||
|
- **S-07: Härtung der KDF-Parameter & Passwort-Validierung**:
|
||||||
|
- Anhebung der KDF-Untergrenzen auf RFC 9106 Mindeststandards (Memory 19.456 KiB, Time Cost 2).
|
||||||
|
- Mindestpasswortlänge von 12 Zeichen bei `init` und `passwd`.
|
||||||
|
- Verbot identischer Passwörter oder Präfix-Kollisionen zwischen Standard- und Hidden-Vault.
|
||||||
|
- **S-08: Path-Traversal-Schutz & Dateinamen-Validierung**:
|
||||||
|
- Umfassende Dateinamenprüfung `validate_node_name` in `sanctum sync` und VFS: Verbot von Pfadtrennern (`/`, `\`), Null-Bytes, Steuerzeichen (< 0x20), `.` / `..` sowie reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
||||||
|
- Strenge Pfadausbruchsprüfung bei `sync pull`.
|
||||||
|
- **S-09: Sichere Einlesung von Wiederherstellungsschlüsseln**:
|
||||||
|
- CLI-String-Übergabe von 24-Wort Notfallschlüsseln via `--recovery-key <PHRASE>` ist veraltet (Deprecation Warning).
|
||||||
|
- Unterstützung für interaktive maskierte Eingabe (kein History-Leak), Stdin (`--recovery-key -`) und Umgebungsvariable `SANCTUM_RECOVERY_KEY`.
|
||||||
|
- **S-10: SHA-256 Checksummenprüfung in Sync & WAL-Sicherheit**:
|
||||||
|
- Echte SHA-256 Hash-Vergleiche bei `sanctum sync --checksum`.
|
||||||
|
- Atomare SQLite-Transaktionen und garantierte WAL-Bereinigung.
|
||||||
|
- **S-11: Sichere Trennung bei Dateinamen-Entschlüsselung**:
|
||||||
|
- Unsicherer statischer Fallback-Schlüssel (`b"SANCTUM_NODE_NAME"`) ist standardmäßig deaktiviert.
|
||||||
|
- Abwärtskompatibilität für Alttresore nur noch bei expliziter Angabe des globalen CLI-Flags `--legacy-names`.
|
||||||
|
|
||||||
## [0.6.0] - 2026-09-16
|
## [0.6.0] - 2026-09-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+9
-1
@@ -1102,6 +1102,12 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1451,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1467,6 +1473,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
|
"minisign-verify",
|
||||||
"rand",
|
"rand",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1475,6 +1482,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.6.0"
|
version = "0.7.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"
|
||||||
@@ -40,6 +40,8 @@ self-replace = "1.5"
|
|||||||
ureq = { version = "2.10", default-features = false, features = ["json", "tls"] }
|
ureq = { version = "2.10", default-features = false, features = ["json", "tls"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
semver = "1.0"
|
semver = "1.0"
|
||||||
|
minisign-verify = "0.2"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
tray-item = "0.10"
|
tray-item = "0.10"
|
||||||
@@ -48,5 +50,5 @@ tray-item = "0.10"
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "unwind"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -28,23 +28,20 @@ Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrische
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Plausible Deniability & Herausgabepflichten (Key Disclosure Laws)
|
## 2. Dual-Vault-Architektur (Second Safe) & 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).
|
Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren Standard-Tresor (Slot 0 / Decoy) wird eine scheinbare Alibi-Datei (Carrier) platziert, die als Träger für den zweiten Tresor (Slot 1 / Second Safe) dient.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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:
|
> **Wichtiger Sicherheitshinweis & Haftungsausschluss für Anwender**:
|
||||||
|
> - **Schutzziel**: Die Dual-Vault-Funktion dient dem Schutz vor neugierigen Blicken (Schulterblick), Diebstahl oder beiläufigem Zwang im Alltag, indem ein plausibler Alibi-Tresor mit unkritischen Daten vorgezeigt werden kann.
|
||||||
1. **Deutschland / Österreich / Schweiz (DACH)**:
|
> - **KEINE forensische "Plausible Deniability"**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält.
|
||||||
* **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.
|
> - **Kein Schutz vor behördlicher Beschlagnahme**: Sanctum garantiert **keine** juristisch oder forensisch unnachweisbare Abstreitbarkeit ("Plausible Deniability") und ist **nicht** als RIPA-49-Schutzschild konzipiert.
|
||||||
* **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).
|
> 1. **Deutschland / Österreich / Schweiz (DACH)**:
|
||||||
2. **Großbritannien (UK - RIPA 2000 Part III, Section 49 & 53)**:
|
> * **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 verfassungsrechtliche Recht zu schweigen.
|
||||||
* 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*).
|
> 2. **Internationale Rechtsordnungen (z. B. UK, USA)**:
|
||||||
* 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.
|
> * In Rechtsordnungen mit strafbewehrten Herausgabeanordnungen (z. B. UK RIPA Section 49) schützt Sanctum nicht vor Verurteilungen wegen Missachtung gerichtlicher Anordnungen, falls Ermittler die Existenz verschlüsselter Trägerblöcke feststellen.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -29,13 +29,14 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
|||||||
```powershell
|
```powershell
|
||||||
sanctum.exe init --path "D:\Tresor\daten.sanctum"
|
sanctum.exe init --path "D:\Tresor\daten.sanctum"
|
||||||
```
|
```
|
||||||
* **Plausible Deniability Tresor (Dual-Vault mit Alibi-Carrier)**:
|
* **Dual-Vault Tresor (Zweittresor mit Alibi-Carrier)**:
|
||||||
```powershell
|
```powershell
|
||||||
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
|
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
|
||||||
```
|
```
|
||||||
* Hier legst du **zwei verschiedene Passwörter** fest:
|
* Hier legst du **zwei verschiedene Passwörter** fest (mindestens 12 Zeichen):
|
||||||
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
|
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.
|
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
|
||||||
|
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
|
||||||
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
|||||||
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
|
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
|
||||||
- **Windows Explorer- & Desktop-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon unter Windows sowie `gio mount` / `.desktop`-MIME unter Linux.
|
- **Windows Explorer- & Desktop-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon unter Windows sowie `gio mount` / `.desktop`-MIME unter Linux.
|
||||||
- **Native Synchronisation (`sanctum sync`)**: RSync-artiger bidirektionaler Dateiabgleich (`Push` & `Pull`) mit 1-MB-Chunk-Streaming direkt in SQLite. Umgeht das fundamentale 4-GB-Dateilimit des Windows WebClient-Dienstes (`0x800700DF`) für Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+). Inklusive `--dry-run` Simulation, `--delete` Spiegelung und `--checksum` Hashprüfung.
|
- **Native Synchronisation (`sanctum sync`)**: RSync-artiger bidirektionaler Dateiabgleich (`Push` & `Pull`) mit 1-MB-Chunk-Streaming direkt in SQLite. Umgeht das fundamentale 4-GB-Dateilimit des Windows WebClient-Dienstes (`0x800700DF`) für Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+). Inklusive `--dry-run` Simulation, `--delete` Spiegelung und `--checksum` Hashprüfung.
|
||||||
- **In-Place Self-Upgrade (`sanctum upgrade`)**: Aktualisiert das laufende Binary per Knopfdruck direkt über Gitea Releases. Löst Windows File-Locking via `self-replace` und verifiziert jedes Release kryptografisch gegen `SHA256SUMS.txt`.
|
- **In-Place Self-Upgrade (`sanctum upgrade`)**: Aktualisiert das laufende Binary per Knopfdruck direkt über Gitea Releases. Löst Windows File-Locking via `self-replace`, verifiziert jedes Release kryptografisch via **Minisign** (`SHA256SUMS.txt.minisig`) und sperrt Drittanbieter-URLs standardmäßig ab.
|
||||||
- **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback.
|
- **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback.
|
||||||
- **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
|
- **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
|
||||||
- **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.
|
- **Dual-Vault / Second Safe**: 2-Slot-Architektur mit Alibi-Trägerdatei (Modell A). Erlaubt die Trennung von Standard- und vertraulichen Zweittresor-Daten (z. B. Schutz vor Schulterblick im Alltag). Volle Dateinamen-Verschlüsselung im Second Safe.
|
||||||
- **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**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
|
- **Statisches Single-Binary**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
|
||||||
@@ -29,16 +29,16 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
|||||||
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
||||||
- **Data Encryption Key (DEK)**:
|
- **Data Encryption Key (DEK)**:
|
||||||
Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
||||||
- **Speichersicherheit (Zeroize)**:
|
- **Speichersicherheit (Zeroize & VirtualLock)**:
|
||||||
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen.
|
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen. Schlüsseldaten werden mittels `VirtualLock` / `mlock` vor Paging geschützt.
|
||||||
- **Chunk-Verschlüsselung (AES-256-GCM)**:
|
- **Chunk-Verschlüsselung (AES-256-GCM)**:
|
||||||
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
||||||
- **Swap-Attack-Schutz**:
|
- **Swap-Attack-Schutz**:
|
||||||
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
||||||
- **Plausible Deniability (Multi-Slot)**:
|
- **Dual-Vault (Multi-Slot & Carrier)**:
|
||||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Hidden Vault oder CSPRNG-Dummy. Ein Angreifer kann mathematisch nicht feststellen, ob Slot 1 ungenutzt ist oder einen zweiten Tresor birgt.
|
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristische "Plausible Deniability" gegen behördliche Beschlagnahme).
|
||||||
- **Dateinamen-Verschlüsselung**:
|
- **Dateinamen-Verschlüsselung**:
|
||||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert.
|
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||||
- **Kryptografisches Chunk-Shredding**:
|
- **Kryptografisches Chunk-Shredding**:
|
||||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
|
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
|
|||||||
# Standard-Container anlegen:
|
# Standard-Container anlegen:
|
||||||
sanctum.exe init --path "C:\Pfad\tresor.sanctum"
|
sanctum.exe init --path "C:\Pfad\tresor.sanctum"
|
||||||
|
|
||||||
# Container mit Plausible Deniability (Decoy + Hidden Safe) anlegen:
|
# Container mit Zweittresor (Dual-Vault: Decoy + Second Safe) anlegen:
|
||||||
sanctum.exe init --path "C:\Pfad\tresor.sanctum" --with-hidden
|
sanctum.exe init --path "C:\Pfad\tresor.sanctum" --with-hidden
|
||||||
```
|
```
|
||||||
*(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).*
|
*(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).*
|
||||||
@@ -266,9 +266,10 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Erzeugt:
|
Erzeugt:
|
||||||
- `dist/sanctum-v0.6.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
- `dist/sanctum-v0.7.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
||||||
- `dist/sanctum-v0.6.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
- `dist/sanctum-v0.7.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
||||||
- `dist/SHA256SUMS.txt`
|
- `dist/SHA256SUMS.txt`
|
||||||
|
- `dist/SHA256SUMS.txt.minisig` (Kryptografische Minisign-Release-Signatur)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -276,8 +277,8 @@ Erzeugt:
|
|||||||
|
|
||||||
- **Kryptografie-Exportkontrolle (EAR & EU Dual-Use)**:
|
- **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.
|
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**:
|
- **Dual-Vault-Architektur & 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).
|
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) eine praktische Zweittresor-Funktion zum Schutz vor Schulterblick und beiläufiger Nötigung. Dies ist ein technischer Alltags-Schutzmechanismus, kein juristisches Schutzschild und keine forensisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme.
|
||||||
- **Datenschutz & Telemetrie-Freiheit (DSGVO / GDPR)**:
|
- **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*).
|
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)**:
|
- **Haftungsbeschränkung (EU/DACH Recht)**:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
untrusted comment: minisign public key E735BF824F9856AC
|
||||||
|
RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU
|
||||||
@@ -106,6 +106,22 @@ $AllLines += "$ElfHash sanctum"
|
|||||||
|
|
||||||
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||||
|
|
||||||
|
# Minisign Signatur aktualisieren (S-02)
|
||||||
|
Write-Host "`nSigniere aktualisierte Pruefsummen mit Minisign..." -ForegroundColor Yellow
|
||||||
|
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
|
||||||
|
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
|
||||||
|
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||||
|
|
||||||
|
if (Test-Path $KeyFile) {
|
||||||
|
if (Test-Path $MinisignExe) {
|
||||||
|
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
||||||
|
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
||||||
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
|
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
|
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
|
||||||
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
|
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
|
||||||
|
|
||||||
@@ -116,4 +132,7 @@ Write-Host " Archiv: $TarGzFile ($TarSizeMB MB)"
|
|||||||
Write-Host " TAR SHA-256: $TarHash"
|
Write-Host " TAR SHA-256: $TarHash"
|
||||||
Write-Host " ELF SHA-256: $ElfHash"
|
Write-Host " ELF SHA-256: $ElfHash"
|
||||||
Write-Host " Checksum-File: $ChecksumFile"
|
Write-Host " Checksum-File: $ChecksumFile"
|
||||||
|
if (Test-Path $SigFile) {
|
||||||
|
Write-Host " Minisign Sig: $SigFile"
|
||||||
|
}
|
||||||
Write-Host "`nBereit fuer Gitea Release."
|
Write-Host "`nBereit fuer Gitea Release."
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ $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
|
||||||
|
|
||||||
|
# 5. Minisign Signatur generieren (S-02)
|
||||||
|
Write-Host "`n[5/5] Signiere Pruefsummen mit Minisign..." -ForegroundColor Yellow
|
||||||
|
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
|
||||||
|
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
|
||||||
|
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||||
|
|
||||||
|
if (Test-Path $KeyFile) {
|
||||||
|
if (Test-Path $MinisignExe) {
|
||||||
|
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
||||||
|
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
||||||
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
|
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Error "Minisign-Signierung fehlgeschlagen!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "minisign.exe nicht gefunden ($MinisignExe). Signatur wurde uebersprungen."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "sanctum-release.key nicht gefunden ($KeyFile). Signatur wurde uebersprungen."
|
||||||
|
}
|
||||||
|
|
||||||
# Abschluss-Zusammenfassung
|
# Abschluss-Zusammenfassung
|
||||||
$ZipSize = (Get-Item $ZipFile).Length / 1MB
|
$ZipSize = (Get-Item $ZipFile).Length / 1MB
|
||||||
Write-Host "`n============================================================" -ForegroundColor Green
|
Write-Host "`n============================================================" -ForegroundColor Green
|
||||||
@@ -110,4 +133,7 @@ Write-Host " Archiv: dist\$PackageName.zip ($([math]::Round($ZipSize, 2)
|
|||||||
Write-Host " ZIP SHA-256: $ZipHash"
|
Write-Host " ZIP SHA-256: $ZipHash"
|
||||||
Write-Host " EXE SHA-256: $ExeHash"
|
Write-Host " EXE SHA-256: $ExeHash"
|
||||||
Write-Host " Checksum-File: dist\SHA256SUMS.txt"
|
Write-Host " Checksum-File: dist\SHA256SUMS.txt"
|
||||||
|
if (Test-Path $SigFile) {
|
||||||
|
Write-Host " Minisign Sig: dist\SHA256SUMS.txt.minisig"
|
||||||
|
}
|
||||||
Write-Host "`nBereit fuer Gitea Release / Verteilung.`n"
|
Write-Host "`nBereit fuer Gitea Release / Verteilung.`n"
|
||||||
|
|||||||
@@ -155,8 +155,10 @@ Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_6
|
|||||||
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
|
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
|
||||||
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
|
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
|
||||||
|
|
||||||
# SHA256SUMS.txt hochladen
|
# SHA256SUMS.txt & Minisign-Signatur hochladen
|
||||||
Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
|
Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
|
||||||
|
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||||
|
Upload-ReleaseAsset -FilePath $SigFile -AssetName "SHA256SUMS.txt.minisig"
|
||||||
|
|
||||||
|
|
||||||
Write-Host "`n============================================================" -ForegroundColor Green
|
Write-Host "`n============================================================" -ForegroundColor Green
|
||||||
|
|||||||
+138
-41
@@ -40,12 +40,74 @@ impl Default for KdfParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const MIN_MEMORY_COST_KIB: u32 = 19_456;
|
||||||
|
pub const MAX_MEMORY_COST_KIB: u32 = 262_144;
|
||||||
|
pub const MIN_TIME_COST: u32 = 2;
|
||||||
|
pub const MAX_TIME_COST: u32 = 8;
|
||||||
|
pub const MIN_PARALLELISM: u32 = 1;
|
||||||
|
pub const MAX_PARALLELISM: u32 = 8;
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 12;
|
||||||
|
|
||||||
|
/// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04).
|
||||||
|
pub fn validate_kdf_params(params: &KdfParams) -> Result<()> {
|
||||||
|
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(¶ms.memory_cost) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)",
|
||||||
|
params.memory_cost,
|
||||||
|
MIN_MEMORY_COST_KIB,
|
||||||
|
MAX_MEMORY_COST_KIB
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !(MIN_TIME_COST..=MAX_TIME_COST).contains(¶ms.time_cost) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})",
|
||||||
|
params.time_cost,
|
||||||
|
MIN_TIME_COST,
|
||||||
|
MAX_TIME_COST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(¶ms.parallelism) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})",
|
||||||
|
params.parallelism,
|
||||||
|
MIN_PARALLELISM,
|
||||||
|
MAX_PARALLELISM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10).
|
||||||
|
pub fn validate_password(password: &str) -> Result<()> {
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LENGTH {
|
||||||
|
bail!(
|
||||||
|
"Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).",
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
password.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist.
|
||||||
|
pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
|
||||||
|
if pass0 == pass1 {
|
||||||
|
bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!");
|
||||||
|
} else if pass0.starts_with(pass1) || pass1.starts_with(pass0) {
|
||||||
|
bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit.");
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
|
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
|
||||||
pub fn derive_kek(
|
pub fn derive_kek(
|
||||||
password: &str,
|
password: &str,
|
||||||
salt: &[u8],
|
salt: &[u8],
|
||||||
params: &KdfParams,
|
params: &KdfParams,
|
||||||
) -> Result<Zeroizing<[u8; 32]>> {
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
validate_kdf_params(params)?;
|
||||||
|
|
||||||
let argon2_params = Params::new(
|
let argon2_params = Params::new(
|
||||||
params.memory_cost,
|
params.memory_cost,
|
||||||
params.time_cost,
|
params.time_cost,
|
||||||
@@ -56,10 +118,13 @@ pub fn derive_kek(
|
|||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
let mut kek = Zeroizing::new([0u8; 32]);
|
let mut kek = Zeroizing::new([0u8; 32]);
|
||||||
|
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
|
||||||
|
|
||||||
argon2
|
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
|
||||||
.hash_password_into(password.as_bytes(), salt, &mut *kek)
|
if let Err(e) = res {
|
||||||
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
|
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
|
||||||
|
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(kek)
|
Ok(kek)
|
||||||
}
|
}
|
||||||
@@ -67,6 +132,7 @@ pub fn derive_kek(
|
|||||||
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
|
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
|
||||||
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
||||||
let mut dek = Zeroizing::new([0u8; 32]);
|
let mut dek = Zeroizing::new([0u8; 32]);
|
||||||
|
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
||||||
OsRng.fill_bytes(&mut *dek);
|
OsRng.fill_bytes(&mut *dek);
|
||||||
dek
|
dek
|
||||||
}
|
}
|
||||||
@@ -200,6 +266,17 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
|||||||
aad
|
aad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
|
||||||
|
pub fn set_allow_legacy_names(allow: bool) {
|
||||||
|
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_legacy_names() -> bool {
|
||||||
|
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
|
/// 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
|
/// 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.
|
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
|
||||||
@@ -207,12 +284,16 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
|||||||
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
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 = match Aes256Gcm::new_from_slice(dek) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
let mut buffer = name.as_bytes().to_vec();
|
let mut buffer = name.as_bytes().to_vec();
|
||||||
let aad = build_name_aad(parent_id);
|
let aad = build_name_aad(parent_id);
|
||||||
let tag = cipher
|
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
|
||||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
|
Ok(t) => t,
|
||||||
.expect("Name encryption");
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||||
combined.extend_from_slice(&nonce_bytes);
|
combined.extend_from_slice(&nonce_bytes);
|
||||||
combined.extend_from_slice(tag.as_slice());
|
combined.extend_from_slice(tag.as_slice());
|
||||||
@@ -221,9 +302,9 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
|
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
|
||||||
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
|
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
|
||||||
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
|
||||||
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
// 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();
|
||||||
@@ -250,18 +331,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
return String::from_utf8(buffer).ok();
|
return String::from_utf8(buffer).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
|
// 2. Fallback: Statische AAD nur wenn explizit aktiviert (S-10)
|
||||||
let mut buffer_legacy = ct_bytes;
|
if allow_legacy {
|
||||||
if cipher
|
let mut buffer_legacy = ct_bytes;
|
||||||
.decrypt_in_place_detached(
|
if cipher
|
||||||
Nonce::from_slice(&nonce_bytes),
|
.decrypt_in_place_detached(
|
||||||
b"SANCTUM_NODE_NAME",
|
Nonce::from_slice(&nonce_bytes),
|
||||||
&mut buffer_legacy,
|
b"SANCTUM_NODE_NAME",
|
||||||
Tag::from_slice(&tag_bytes),
|
&mut buffer_legacy,
|
||||||
)
|
Tag::from_slice(&tag_bytes),
|
||||||
.is_ok()
|
)
|
||||||
{
|
.is_ok()
|
||||||
return String::from_utf8(buffer_legacy).ok();
|
{
|
||||||
|
return String::from_utf8(buffer_legacy).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,18 +376,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
return String::from_utf8(buffer).ok();
|
return String::from_utf8(buffer).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Alte statische AAD für bestehende Container
|
// 2. Fallback: Alte statische AAD nur wenn explizit aktiviert (S-10)
|
||||||
let mut buffer_legacy = ct.to_vec();
|
if allow_legacy {
|
||||||
if cipher
|
let mut buffer_legacy = ct.to_vec();
|
||||||
.decrypt_in_place_detached(
|
if cipher
|
||||||
Nonce::from_slice(nonce),
|
.decrypt_in_place_detached(
|
||||||
b"SANCTUM_NODE_NAME",
|
Nonce::from_slice(nonce),
|
||||||
&mut buffer_legacy,
|
b"SANCTUM_NODE_NAME",
|
||||||
Tag::from_slice(tag),
|
&mut buffer_legacy,
|
||||||
)
|
Tag::from_slice(tag),
|
||||||
.is_ok()
|
)
|
||||||
{
|
.is_ok()
|
||||||
return String::from_utf8(buffer_legacy).ok();
|
{
|
||||||
|
return String::from_utf8(buffer_legacy).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,6 +398,10 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
||||||
|
decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names())
|
||||||
|
}
|
||||||
|
|
||||||
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
|
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
|
||||||
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||||
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
||||||
@@ -545,7 +634,9 @@ pub fn decrypt_chunk(
|
|||||||
if payload.len() < 4 {
|
if payload.len() < 4 {
|
||||||
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
|
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;
|
let mut size_bytes = [0u8; 4];
|
||||||
|
size_bytes.copy_from_slice(&payload[0..4]);
|
||||||
|
let uncompressed_size = u32::from_le_bytes(size_bytes) as usize;
|
||||||
if uncompressed_size > CHUNK_SIZE {
|
if uncompressed_size > CHUNK_SIZE {
|
||||||
bail!(
|
bail!(
|
||||||
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
||||||
@@ -574,8 +665,8 @@ mod tests {
|
|||||||
let password = "SuperSecretMasterPassword123!";
|
let password = "SuperSecretMasterPassword123!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let params = KdfParams {
|
let params = KdfParams {
|
||||||
memory_cost: 1024, // Schneller für Unit-Test
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -787,8 +878,14 @@ mod tests {
|
|||||||
hex::encode(static_tag),
|
hex::encode(static_tag),
|
||||||
hex::encode(&static_buf)
|
hex::encode(&static_buf)
|
||||||
);
|
);
|
||||||
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
|
// Standardmäßig muss Legacy-AAD abgewiesen werden (S-07)
|
||||||
|
assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none());
|
||||||
|
|
||||||
|
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
|
||||||
|
set_allow_legacy_names(true);
|
||||||
|
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
|
||||||
assert_eq!(decrypted_static, filename);
|
assert_eq!(decrypted_static, filename);
|
||||||
|
set_allow_legacy_names(false);
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -807,7 +904,6 @@ mod tests {
|
|||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||||
|
|
||||||
// Gültige parent_ids entschlüsseln erfolgreich
|
// 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, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
||||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
||||||
@@ -819,8 +915,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_model_a_slot_payloads() {
|
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 test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
|
||||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
|
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
let dek_0 = generate_dek();
|
let dek_0 = generate_dek();
|
||||||
let dek_1 = generate_dek();
|
let dek_1 = generate_dek();
|
||||||
let carrier_node_id = 42i64;
|
let carrier_node_id = 42i64;
|
||||||
|
|||||||
+144
-91
@@ -6,12 +6,14 @@ use tracing_subscriber::EnvFilter;
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
|
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
|
||||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
mnemonic_to_dek, set_allow_legacy_names, validate_password, 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_slot_from_recovery_key,
|
detect_recovery_key_slot, 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;
|
||||||
@@ -23,6 +25,10 @@ use sanctum::verify::verify_container;
|
|||||||
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
/// Erlaubt Legacy-Namensentschlüsselung ohne deterministisches AAD (S-07)
|
||||||
|
#[arg(long, global = true, default_value_t = false)]
|
||||||
|
legacy_names: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
}
|
}
|
||||||
@@ -35,7 +41,7 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
||||||
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
|
/// Erstellt einen separaten, isolierten zweiten Tresor (Dual-Vault / Second Safe)
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
with_hidden: bool,
|
with_hidden: bool,
|
||||||
|
|
||||||
@@ -267,6 +273,10 @@ enum Commands {
|
|||||||
/// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu)
|
/// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu)
|
||||||
#[arg(long, default_value = "https://gitea.pansi.eu")]
|
#[arg(long, default_value = "https://gitea.pansi.eu")]
|
||||||
url: String,
|
url: String,
|
||||||
|
|
||||||
|
/// Erlaubt abweichende Update-Server-URLs (erfordert interaktive Bestätigung)
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
insecure_url: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,11 +312,51 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liest den 24-Wort BIP-39 Notfallschlüssel sicher ein.
|
||||||
|
/// Unterstützt:
|
||||||
|
/// 1. Interaktiv maskiert (wenn kein Wert oder leere Eingabe übergeben)
|
||||||
|
/// 2. Stdin (wenn "-" übergeben)
|
||||||
|
/// 3. Umgebungsvariable 'SANCTUM_RECOVERY_KEY' (wenn kein CLI-Argument übergeben)
|
||||||
|
/// 4. Veraltetes CLI-Argument mit Sicherheitswarnung (S-09)
|
||||||
|
fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>> {
|
||||||
|
if let Some(key) = arg {
|
||||||
|
if key == "-" {
|
||||||
|
println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan("ℹ"));
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
bail!("Der Notfallschlüssel von stdin darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
Ok(Some(Zeroizing::new(trimmed.to_string())))
|
||||||
|
} else if key.trim().is_empty() {
|
||||||
|
Ok(Some(prompt_recovery_key_interactive()?))
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{} WARNUNG: Die Angabe des Notfallschlüssels als CLI-Argument ist unsicher und veraltet!",
|
||||||
|
ui::yellow("[SICHERHEITSHINWEIS]")
|
||||||
|
);
|
||||||
|
eprintln!(" Verwenden Sie '--recovery-key' ohne Wert (interaktiv), '--recovery-key -' (stdin) oder die Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.");
|
||||||
|
Ok(Some(Zeroizing::new(key.to_string())))
|
||||||
|
}
|
||||||
|
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
|
||||||
|
let trimmed = env_key.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ"));
|
||||||
|
Ok(Some(Zeroizing::new(trimmed.to_string())))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
|
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
|
||||||
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
|
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
|
||||||
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
|
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
|
||||||
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan("ℹ"));
|
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"));
|
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec"));
|
||||||
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
|
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
|
||||||
.context("Fehler beim Einlesen des Notfallschlüssels")?;
|
.context("Fehler beim Einlesen des Notfallschlüssels")?;
|
||||||
if phrase.trim().is_empty() {
|
if phrase.trim().is_empty() {
|
||||||
@@ -333,15 +383,12 @@ fn handle_init(
|
|||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!(" Zieldatei: {}", container_path.display());
|
println!(" Zieldatei: {}", container_path.display());
|
||||||
if with_hidden {
|
if with_hidden {
|
||||||
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
|
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei"));
|
||||||
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
||||||
let lower = carrier_name.to_lowercase();
|
println!(
|
||||||
if lower.ends_with(".iso") || lower.ends_with(".vhd") || lower.ends_with(".img") || lower.ends_with(".zip") {
|
" [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.",
|
||||||
println!(
|
ui::yellow("Info")
|
||||||
" [{}] 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!();
|
||||||
|
|
||||||
@@ -356,9 +403,8 @@ fn handle_init(
|
|||||||
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
if password_0.trim().is_empty() {
|
validate_password(&password_0)?;
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
let confirm_0 = Zeroizing::new(
|
let confirm_0 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
@@ -368,18 +414,15 @@ fn handle_init(
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
|
println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───");
|
||||||
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 = Zeroizing::new(
|
let password_1 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
if password_1.trim().is_empty() {
|
validate_password(&password_1)?;
|
||||||
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
|
check_password_prefix_collision(&password_0, &password_1)?;
|
||||||
}
|
|
||||||
if *password_1 == *password_0 {
|
|
||||||
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
|
|
||||||
}
|
|
||||||
let confirm_1 = Zeroizing::new(
|
let confirm_1 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
@@ -398,33 +441,36 @@ fn handle_init(
|
|||||||
let kdf_params_1 = KdfParams::default();
|
let kdf_params_1 = KdfParams::default();
|
||||||
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
|
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
|
||||||
|
|
||||||
ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG...");
|
ui::step(2, 4, "🎲", "Erzeuge unabhängige DEKs (DEK_0 & DEK_1)...");
|
||||||
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 Slot-Payloads für Modell A via AES-256-GCM...");
|
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...");
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
let (wrapped_dek_0, header_nonce_0, header_tag_0) =
|
||||||
let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
||||||
|
|
||||||
ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
|
ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...");
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||||
|
|
||||||
|
let (wrapped_dek_1, header_nonce_1, header_tag_1) =
|
||||||
|
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
||||||
|
|
||||||
db.init_schema_with_carrier(
|
db.init_schema_with_carrier(
|
||||||
&salt_0,
|
&salt_0,
|
||||||
&kdf_params_0,
|
&kdf_params_0,
|
||||||
&wrapped_dek_0,
|
&wrapped_dek_0,
|
||||||
&nonce_0,
|
&header_nonce_0,
|
||||||
&tag_0,
|
&header_tag_0,
|
||||||
Some((
|
Some((
|
||||||
carrier_name,
|
carrier_name,
|
||||||
carrier_size_bytes,
|
carrier_size_bytes,
|
||||||
&salt_1,
|
&salt_1,
|
||||||
&kdf_params_1,
|
&kdf_params_1,
|
||||||
&wrapped_dek_1,
|
&wrapped_dek_1,
|
||||||
&nonce_1,
|
&header_nonce_1,
|
||||||
&tag_1,
|
&header_tag_1,
|
||||||
&dek_0,
|
&dek_0,
|
||||||
&dek_1,
|
&dek_1,
|
||||||
)),
|
)),
|
||||||
@@ -437,16 +483,16 @@ fn handle_init(
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! │");
|
println!("│ ✔ Sanctum Dual-Vault Container 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!(" • Carrier-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!(" • Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
|
println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
|
||||||
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
||||||
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
|
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
|
||||||
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())));
|
||||||
@@ -467,10 +513,7 @@ fn handle_init(
|
|||||||
rpassword::prompt_password("Master-Passwort eingeben: ")
|
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&password)?;
|
||||||
if password.trim().is_empty() {
|
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
||||||
@@ -517,7 +560,7 @@ fn handle_init(
|
|||||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||||
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
||||||
println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt");
|
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
|
||||||
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())));
|
||||||
@@ -592,16 +635,26 @@ 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, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
|
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(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(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||||
(d, 0, None, None)
|
|
||||||
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0);
|
||||||
|
if detected_slot == 1 {
|
||||||
|
println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault).");
|
||||||
|
println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||||
|
let decoy_pw = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||||
|
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||||
|
);
|
||||||
|
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
|
let c_dek = decoy_auth.dek().clone();
|
||||||
|
(d, 1, Some(c_dek), db.find_carrier_node_id()?)
|
||||||
|
} else {
|
||||||
|
(d, 0, None, db.find_carrier_node_id()?)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let old_password = Zeroizing::new(
|
let old_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||||
@@ -632,10 +685,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
||||||
.context("Fehler beim Einlesen des neuen Passworts")?,
|
.context("Fehler beim Einlesen des neuen Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&new_password)?;
|
||||||
if new_password.trim().is_empty() {
|
|
||||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
@@ -654,7 +704,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
||||||
|
|
||||||
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
||||||
let c_dek = carrier_dek.unwrap_or_else(generate_dek);
|
let c_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
|
||||||
let c_nid = carrier_node_id.unwrap_or(0);
|
let c_nid = carrier_node_id.unwrap_or(0);
|
||||||
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
||||||
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||||
@@ -810,12 +860,8 @@ fn handle_restore_header(
|
|||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
||||||
} else {
|
} else {
|
||||||
let key_str = if let Some(key) = recovery_key {
|
let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? {
|
||||||
if key.trim().is_empty() {
|
key
|
||||||
prompt_recovery_key_interactive()?
|
|
||||||
} else {
|
|
||||||
Zeroizing::new(key.to_string())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Interaktiver Auswahldialog statt Fehlermeldung
|
// Interaktiver Auswahldialog statt Fehlermeldung
|
||||||
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
|
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
|
||||||
@@ -840,15 +886,21 @@ fn handle_restore_header(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
|
let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
|
let effective_slot = if slot == 0 {
|
||||||
|
detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
slot
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot);
|
||||||
let new_password = Zeroizing::new(
|
let new_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&new_password)?;
|
||||||
if new_password.trim().is_empty() {
|
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
@@ -859,9 +911,24 @@ fn handle_restore_header(
|
|||||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dek_0_holder: Option<[u8; 32]> = if effective_slot == 1 {
|
||||||
|
println!(" Wiederherstellung von Slot 1 (Hidden Vault):");
|
||||||
|
println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||||
|
let decoy_pw = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||||
|
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||||
|
);
|
||||||
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
|
Some(**decoy_auth.dek())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let dek_0_opt = dek_0_holder.as_ref();
|
||||||
|
|
||||||
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
||||||
restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?;
|
restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?;
|
||||||
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
|
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot));
|
||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
||||||
}
|
}
|
||||||
@@ -962,18 +1029,8 @@ fn handle_sync(
|
|||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth = if let Some(key_arg) = recovery_key {
|
let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? {
|
||||||
let phrase_str = if key_arg.trim().is_empty() {
|
ContainerAuth::RecoveryKey(phrase_str)
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
|
||||||
println!("│ 🔑 Maskierte interaktive Notfallschlüssel-Eingabe │");
|
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
|
||||||
println!();
|
|
||||||
rpassword::prompt_password("24-Wort Notfall-Wiederherstellungsschlüssel: ")
|
|
||||||
.context("Fehler beim Einlesen des Schlüssels")?
|
|
||||||
} else {
|
|
||||||
key_arg.to_string()
|
|
||||||
};
|
|
||||||
ContainerAuth::RecoveryKey(Zeroizing::new(phrase_str))
|
|
||||||
} else {
|
} else {
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │");
|
println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │");
|
||||||
@@ -999,11 +1056,7 @@ fn handle_sync(
|
|||||||
ContainerAuth::RecoveryKey(ref phrase) => {
|
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||||
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")?;
|
||||||
let is_hidden = {
|
let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0);
|
||||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
|
||||||
!children.is_empty()
|
|
||||||
};
|
|
||||||
let v_id = if is_hidden { 1 } else { 0 };
|
|
||||||
(dek, meta.version, v_id)
|
(dek, meta.version, v_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1102,6 +1155,10 @@ fn handle_sync(
|
|||||||
async fn run() -> Result<()> {
|
async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.legacy_names {
|
||||||
|
set_allow_legacy_names(true);
|
||||||
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Init {
|
Commands::Init {
|
||||||
path,
|
path,
|
||||||
@@ -1154,12 +1211,7 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let auth = if let Some(key) = recovery_key {
|
let auth = if let Some(actual_key) = resolve_recovery_key(recovery_key.as_deref())? {
|
||||||
let actual_key = if key.trim().is_empty() {
|
|
||||||
prompt_recovery_key_interactive()?
|
|
||||||
} else {
|
|
||||||
Zeroizing::new(key)
|
|
||||||
};
|
|
||||||
ContainerAuth::RecoveryKey(actual_key)
|
ContainerAuth::RecoveryKey(actual_key)
|
||||||
} else {
|
} else {
|
||||||
let prompt_text = format!(
|
let prompt_text = format!(
|
||||||
@@ -1266,12 +1318,13 @@ async fn run() -> Result<()> {
|
|||||||
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
Commands::Upgrade { check, yes, force, url } => {
|
Commands::Upgrade { check, yes, force, url, insecure_url } => {
|
||||||
let options = sanctum::upgrade::UpgradeOptions {
|
let options = sanctum::upgrade::UpgradeOptions {
|
||||||
check_only: check,
|
check_only: check,
|
||||||
yes,
|
yes,
|
||||||
force,
|
force,
|
||||||
base_url: url,
|
base_url: url,
|
||||||
|
insecure_url,
|
||||||
};
|
};
|
||||||
sanctum::upgrade::run_upgrade(&options)?;
|
sanctum::upgrade::run_upgrade(&options)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-14
@@ -209,13 +209,27 @@ pub async fn mount_container(
|
|||||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
|
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
let is_hidden = {
|
let (carrier_dek, final_carrier_node_id, vault_id) = if carrier_node_id > 0 {
|
||||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
let slot_id = crate::recovery::detect_recovery_key_slot(&db, &dek, meta.version)?;
|
||||||
!children.is_empty()
|
if slot_id == 1 {
|
||||||
|
if !stealth {
|
||||||
|
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
|
||||||
|
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
|
||||||
|
}
|
||||||
|
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
|
||||||
|
let decoy_keys = meta.authenticate(&decoy_pass)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
|
||||||
|
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
|
||||||
|
} else {
|
||||||
|
(None, Some(carrier_node_id), 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None, 0)
|
||||||
};
|
};
|
||||||
let vault_id = if is_hidden { 1 } else { 0 };
|
|
||||||
(dek, None, None, meta.version, vault_id)
|
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -402,12 +416,12 @@ pub async fn mount_container(
|
|||||||
println!(" • Modus: WebDAV Userland-VFS");
|
println!(" • Modus: WebDAV Userland-VFS");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
|
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/{}/", bound_port, session_token);
|
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
|
||||||
if let Some(mp) = mount_point {
|
if let Some(mp) = mount_point {
|
||||||
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/{}/ {}", bound_port, session_token, mp.display());
|
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(secs) = idle_timeout {
|
if let Some(secs) = idle_timeout {
|
||||||
@@ -602,15 +616,63 @@ pub async fn mount_container(
|
|||||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
// S-10: Verifikation und Bereinigung von temporären SQLite WAL- und SHM-Dateien
|
||||||
|
let base_os = container_path.as_os_str().to_os_string();
|
||||||
|
let mut wal = base_os.clone();
|
||||||
|
wal.push("-wal");
|
||||||
|
let _ = std::fs::remove_file(wal);
|
||||||
|
let mut shm = base_os;
|
||||||
|
shm.push("-shm");
|
||||||
|
let _ = std::fs::remove_file(shm);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Überprüft, ob ein Host-Header auf Loopback (127.0.0.1, localhost, [::1]) zeigt (Anti-DNS-Rebinding).
|
/// Überprüft, ob ein Host-Header exakt auf Loopback (127.0.0.1, localhost, [::1], ::1) zeigt (Anti-DNS-Rebinding & S-06).
|
||||||
|
/// Striktes Parsing von Host und Port ohne anfällige starts_with-Präfixprüfungen.
|
||||||
pub fn is_loopback_host(host_str: &str) -> bool {
|
pub fn is_loopback_host(host_str: &str) -> bool {
|
||||||
let host_lower = host_str.to_ascii_lowercase();
|
let host_trimmed = host_str.trim().to_ascii_lowercase();
|
||||||
host_lower.starts_with("127.0.0.1")
|
if host_trimmed.is_empty() {
|
||||||
|| host_lower.starts_with("localhost")
|
return false;
|
||||||
|| host_lower.starts_with("[::1]")
|
}
|
||||||
|
|
||||||
|
if host_trimmed == "::1" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_part = if host_trimmed.starts_with('[') {
|
||||||
|
// IPv6 bracketed: [::1] oder [::1]:port
|
||||||
|
if let Some(bracket_end) = host_trimmed.find(']') {
|
||||||
|
let inside = &host_trimmed[1..bracket_end];
|
||||||
|
let after = &host_trimmed[bracket_end + 1..];
|
||||||
|
if !after.is_empty() {
|
||||||
|
if !after.starts_with(':') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if after[1..].parse::<u16>().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inside
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// IPv4 oder Hostname: localhost, localhost:8443, 127.0.0.1, 127.0.0.1:8443
|
||||||
|
if let Some((h, p)) = host_trimmed.rsplit_once(':') {
|
||||||
|
if p.parse::<u16>().is_ok() {
|
||||||
|
h
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
&host_trimmed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||||
|
|||||||
+38
-13
@@ -6,8 +6,8 @@ use anyhow::{bail, Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
|
decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
|
||||||
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
||||||
|
|
||||||
@@ -222,6 +222,30 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ermittelt automatisch, ob ein Notfallschlüssel zu Slot 0 (Standard/Decoy) oder Slot 1 (Hidden Vault) gehört (S-05).
|
||||||
|
pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> Result<u32> {
|
||||||
|
if let Ok(Some(carrier_id)) = db.find_carrier_node_id() {
|
||||||
|
if let Ok(Some(chunk0)) = db.read_chunk(carrier_id, 0) {
|
||||||
|
let is_dek0 = decrypt_chunk(
|
||||||
|
dek,
|
||||||
|
carrier_id,
|
||||||
|
0,
|
||||||
|
&chunk0.ciphertext,
|
||||||
|
&chunk0.nonce,
|
||||||
|
&chunk0.tag,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
if is_dek0 {
|
||||||
|
return Ok(0);
|
||||||
|
} else {
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rekonstruiert einen Slot des Container-Headers 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 für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
||||||
pub fn restore_slot_from_recovery_key(
|
pub fn restore_slot_from_recovery_key(
|
||||||
@@ -229,14 +253,13 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
recovery_key: &str,
|
recovery_key: &str,
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
target_slot_id: u32,
|
target_slot_id: u32,
|
||||||
|
dek_0: Option<&[u8; 32]>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_password.trim().is_empty() {
|
validate_password(new_password)?;
|
||||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
||||||
let dek = mnemonic_to_dek(recovery_key)
|
let dek = mnemonic_to_dek(recovery_key)
|
||||||
@@ -258,9 +281,11 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
let mut existing_slots = db.read_slots().unwrap_or_default();
|
let mut existing_slots = db.read_slots().unwrap_or_default();
|
||||||
|
|
||||||
let new_slot = if target_slot_id == 1 {
|
let new_slot = if target_slot_id == 1 {
|
||||||
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
|
// Für Slot 1 wird der echte DEK_0 benötigt (S-05)
|
||||||
let dek_0_bytes = [0u8; 32];
|
let dek_0_bytes = dek_0.ok_or_else(|| {
|
||||||
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
|
anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
|
||||||
|
})?;
|
||||||
|
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
|
||||||
SlotMeta {
|
SlotMeta {
|
||||||
slot_id: 1,
|
slot_id: 1,
|
||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
@@ -333,7 +358,7 @@ pub fn restore_header_from_recovery_key(
|
|||||||
recovery_key: &str,
|
recovery_key: &str,
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
|
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -358,8 +383,8 @@ mod tests {
|
|||||||
let password = "SuperSecretPassword2026!";
|
let password = "SuperSecretPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -413,8 +438,8 @@ mod tests {
|
|||||||
|
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
+199
-16
@@ -9,7 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
|||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
||||||
KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
|
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
|
||||||
|
MAGIC_BYTES,
|
||||||
};
|
};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
@@ -177,11 +178,79 @@ impl Database {
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// S-04: Untrusted Container & Fremddatei-Schutz.
|
||||||
|
// Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden:
|
||||||
|
// Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen.
|
||||||
|
let table_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if table_count > 0 {
|
||||||
|
let has_sanctum_schema: bool = {
|
||||||
|
let has_meta: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !has_meta {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
let magic_opt: Option<Option<Vec<u8>>> = conn
|
||||||
|
.query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0))
|
||||||
|
.optional()?;
|
||||||
|
match magic_opt {
|
||||||
|
Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(),
|
||||||
|
Some(None) => false,
|
||||||
|
None => {
|
||||||
|
// Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung).
|
||||||
|
// Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist,
|
||||||
|
// um Fremddatenbanken weiterhin strikt abzuweisen.
|
||||||
|
let has_nodes: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
let has_chunks: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
has_nodes && has_chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_sanctum_schema {
|
||||||
|
bail!(
|
||||||
|
"Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.",
|
||||||
|
path_ref.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
};
|
};
|
||||||
db.init_pragmas()?;
|
db.init_pragmas()?;
|
||||||
db.ensure_schema_upgrades()?;
|
if table_count > 0 {
|
||||||
|
db.ensure_schema_upgrades()?;
|
||||||
|
}
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,10 +273,16 @@ impl Database {
|
|||||||
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
||||||
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
||||||
let meta = self.read_meta()?;
|
let meta = self.read_meta()?;
|
||||||
Ok(meta.authenticate(password))
|
let res = meta.authenticate(password);
|
||||||
|
if let Some(ref keys) = res {
|
||||||
|
if let Some(cid) = keys.carrier_node_id() {
|
||||||
|
let _ = self.mark_carrier_node_id(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
|
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) 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);
|
||||||
@@ -219,6 +294,9 @@ impl Database {
|
|||||||
// Spalte slot_id in meta (falls aus v1 migriert)
|
// 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 is_carrier in nodes (S-03 Carrier-Schutz)
|
||||||
|
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,16 +349,23 @@ 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).
|
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
|
||||||
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
"SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1",
|
||||||
)?;
|
)?;
|
||||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markiert einen existierenden Knoten explizit als Carrier.
|
||||||
|
pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
|
/// 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> {
|
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
|
||||||
if node_id == ancestor_id {
|
if node_id == ancestor_id {
|
||||||
@@ -374,6 +459,7 @@ impl Database {
|
|||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
modified_at INTEGER NOT NULL,
|
modified_at INTEGER NOT NULL,
|
||||||
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||||
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(parent_id, name) WHERE parent_id IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||||
@@ -430,10 +516,10 @@ impl Database {
|
|||||||
dek_1,
|
dek_1,
|
||||||
)) = carrier_config
|
)) = carrier_config
|
||||||
{
|
{
|
||||||
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
|
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen (is_carrier = 1)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
||||||
VALUES (1, ?1, 0, ?2, ?3, ?4)",
|
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
|
||||||
params![c_name, c_size as i64, now, now],
|
params![c_name, c_size as i64, now, now],
|
||||||
)?;
|
)?;
|
||||||
let c_id = conn.last_insert_rowid();
|
let c_id = conn.last_insert_rowid();
|
||||||
@@ -589,6 +675,7 @@ impl Database {
|
|||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
modified_at INTEGER NOT NULL,
|
modified_at INTEGER NOT NULL,
|
||||||
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||||
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(parent_id, name) WHERE parent_id IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||||
@@ -701,19 +788,88 @@ impl Database {
|
|||||||
let tag_vec: Vec<u8> = row.get(6)?;
|
let tag_vec: Vec<u8> = row.get(6)?;
|
||||||
|
|
||||||
let mut kdf_salt = [0u8; 16];
|
let mut kdf_salt = [0u8; 16];
|
||||||
if salt_vec.len() == 16 {
|
if salt_vec.len() != 16 {
|
||||||
kdf_salt.copy_from_slice(&salt_vec);
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
2,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
kdf_salt.copy_from_slice(&salt_vec);
|
||||||
|
|
||||||
let mut header_nonce = [0u8; 12];
|
let mut header_nonce = [0u8; 12];
|
||||||
if nonce_vec.len() == 12 {
|
if nonce_vec.len() != 12 {
|
||||||
header_nonce.copy_from_slice(&nonce_vec);
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
5,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
header_nonce.copy_from_slice(&nonce_vec);
|
||||||
|
|
||||||
let mut header_tag = [0u8; 16];
|
let mut header_tag = [0u8; 16];
|
||||||
if tag_vec.len() == 16 {
|
if tag_vec.len() != 16 {
|
||||||
header_tag.copy_from_slice(&tag_vec);
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
6,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
header_tag.copy_from_slice(&tag_vec);
|
||||||
|
|
||||||
|
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
|
||||||
|
if slot_id == 0 {
|
||||||
|
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
4,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if slot_id == 1 {
|
||||||
|
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
4,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default();
|
let kdf_params: KdfParams = serde_json::from_str(¶ms_str)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
3,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id),
|
||||||
|
)),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
validate_kdf_params(&kdf_params)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
3,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id),
|
||||||
|
)),
|
||||||
|
))?;
|
||||||
|
|
||||||
Ok(SlotMeta {
|
Ok(SlotMeta {
|
||||||
slot_id,
|
slot_id,
|
||||||
@@ -1083,8 +1239,32 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen
|
||||||
|
/// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig
|
||||||
|
/// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03).
|
||||||
|
pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> {
|
||||||
|
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||||
|
if node_id == carrier_id {
|
||||||
|
bail!(
|
||||||
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).",
|
||||||
|
node_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
||||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
pub fn delete_node(&self, id: i64) -> Result<()> {
|
||||||
|
self.assert_not_carrier(id)?;
|
||||||
|
|
||||||
|
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
||||||
|
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||||
|
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
||||||
|
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
||||||
let _ = self.shred_chunks_for_node(id);
|
let _ = self.shred_chunks_for_node(id);
|
||||||
|
|
||||||
@@ -1118,6 +1298,7 @@ impl Database {
|
|||||||
vault_id: u32,
|
vault_id: u32,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
self.assert_not_carrier(id)?;
|
||||||
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 {
|
||||||
@@ -1212,6 +1393,7 @@ impl Database {
|
|||||||
new_size: u64,
|
new_size: u64,
|
||||||
modified_at: u64,
|
modified_at: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
self.assert_not_carrier(node_id)?;
|
||||||
let mut conn = self.conn.lock().unwrap();
|
let mut conn = self.conn.lock().unwrap();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -1240,6 +1422,7 @@ impl Database {
|
|||||||
/// 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<()> {
|
||||||
|
self.assert_not_carrier(node_id)?;
|
||||||
let mut conn = self.conn.lock().unwrap();
|
let mut conn = self.conn.lock().unwrap();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
{
|
{
|
||||||
|
|||||||
+144
-2
@@ -6,11 +6,103 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
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};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::vfs::is_leak_file;
|
use crate::vfs::is_leak_file;
|
||||||
|
|
||||||
|
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
|
||||||
|
pub fn validate_node_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("Dateiname darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
|
||||||
|
}
|
||||||
|
if name.contains('/') || name.contains('\\') || name.contains('\0') {
|
||||||
|
bail!(
|
||||||
|
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for c in name.chars() {
|
||||||
|
if (c as u32) < 0x20 {
|
||||||
|
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
|
||||||
|
let base_name = if let Some(dot_idx) = name.find('.') {
|
||||||
|
&name[..dot_idx]
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
let base_upper = base_name.to_ascii_uppercase();
|
||||||
|
let reserved = [
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
];
|
||||||
|
if reserved.contains(&base_upper.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
|
||||||
|
fn calc_local_file_sha256(path: &Path) -> Result<String> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buffer)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..n]);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10).
|
||||||
|
fn calc_vault_node_sha256(
|
||||||
|
db: &Database,
|
||||||
|
node: &NodeRecord,
|
||||||
|
dek: &[u8; 32],
|
||||||
|
version: u32,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let total_chunks = if node.size == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
for idx in 0..total_chunks {
|
||||||
|
let record = db
|
||||||
|
.read_chunk(node.id, idx)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?;
|
||||||
|
|
||||||
|
let decrypted = decrypt_chunk(
|
||||||
|
dek,
|
||||||
|
node.id,
|
||||||
|
idx,
|
||||||
|
&record.ciphertext,
|
||||||
|
&record.nonce,
|
||||||
|
&record.tag,
|
||||||
|
version,
|
||||||
|
)?;
|
||||||
|
hasher.update(&decrypted);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Synchronisationsrichtung
|
/// Synchronisationsrichtung
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SyncDirection {
|
pub enum SyncDirection {
|
||||||
@@ -180,6 +272,8 @@ pub fn sync_single_file_to_vault(
|
|||||||
checksum: bool,
|
checksum: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<FileTransferResult> {
|
) -> Result<FileTransferResult> {
|
||||||
|
validate_node_name(file_name)?;
|
||||||
|
|
||||||
let meta = fs::metadata(local_path)
|
let meta = fs::metadata(local_path)
|
||||||
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
|
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
|
||||||
let local_size = meta.len();
|
let local_size = meta.len();
|
||||||
@@ -198,10 +292,22 @@ pub fn sync_single_file_to_vault(
|
|||||||
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast Check: Wenn Größe und mtime identisch sind, überspringen
|
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
|
||||||
if !checksum && node.size == local_size && node.modified_at == local_mtime {
|
if !checksum && node.size == local_size && node.modified_at == local_mtime {
|
||||||
return Ok(FileTransferResult::Skipped { size: local_size });
|
return Ok(FileTransferResult::Skipped { size: local_size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
|
||||||
|
if checksum && node.size == local_size {
|
||||||
|
if let (Ok(local_hash), Ok(vault_hash)) = (
|
||||||
|
calc_local_file_sha256(local_path),
|
||||||
|
calc_vault_node_sha256(db, node, dek, version),
|
||||||
|
) {
|
||||||
|
if local_hash == vault_hash {
|
||||||
|
return Ok(FileTransferResult::Skipped { size: local_size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
@@ -264,6 +370,8 @@ pub fn sync_single_file_to_host(
|
|||||||
checksum: bool,
|
checksum: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<FileTransferResult> {
|
) -> Result<FileTransferResult> {
|
||||||
|
validate_node_name(&node.name)?;
|
||||||
|
|
||||||
if local_path.exists() {
|
if local_path.exists() {
|
||||||
if let Ok(meta) = fs::metadata(local_path) {
|
if let Ok(meta) = fs::metadata(local_path) {
|
||||||
let local_size = meta.len();
|
let local_size = meta.len();
|
||||||
@@ -277,6 +385,18 @@ pub fn sync_single_file_to_host(
|
|||||||
if !checksum && local_size == node.size && local_mtime == node.modified_at {
|
if !checksum && local_size == node.size && local_mtime == node.modified_at {
|
||||||
return Ok(FileTransferResult::Skipped { size: node.size });
|
return Ok(FileTransferResult::Skipped { size: node.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
|
||||||
|
if checksum && local_size == node.size {
|
||||||
|
if let (Ok(local_hash), Ok(vault_hash)) = (
|
||||||
|
calc_local_file_sha256(local_path),
|
||||||
|
calc_vault_node_sha256(db, node, dek, version),
|
||||||
|
) {
|
||||||
|
if local_hash == vault_hash {
|
||||||
|
return Ok(FileTransferResult::Skipped { size: node.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +577,7 @@ fn collect_and_push_dir(
|
|||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
validate_node_name(&file_name)?;
|
||||||
|
|
||||||
let rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(base_dir)
|
.strip_prefix(base_dir)
|
||||||
@@ -545,7 +666,7 @@ fn collect_and_push_dir(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_orphans_in_vault(
|
pub fn delete_orphans_in_vault(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
vault_id: u32,
|
vault_id: u32,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
@@ -556,9 +677,15 @@ fn delete_orphans_in_vault(
|
|||||||
quiet: bool,
|
quiet: bool,
|
||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
|
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
|
||||||
|
|
||||||
for child in children {
|
for child in children {
|
||||||
|
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
|
||||||
|
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let child_rel = if prefix_rel.is_empty() {
|
let child_rel = if prefix_rel.is_empty() {
|
||||||
child.name.clone()
|
child.name.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -704,6 +831,8 @@ fn collect_and_pull_dir(
|
|||||||
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
||||||
|
|
||||||
for child in children {
|
for child in children {
|
||||||
|
validate_node_name(&child.name)?;
|
||||||
|
|
||||||
let child_rel = if rel_prefix.is_empty() {
|
let child_rel = if rel_prefix.is_empty() {
|
||||||
child.name.clone()
|
child.name.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -714,9 +843,22 @@ fn collect_and_pull_dir(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S-08 Path Traversal Guard:
|
||||||
|
// Prüfe Komponenten des relativen Pfads und stelle sicher, dass der Pfad nicht ausbricht
|
||||||
|
for comp in Path::new(&child_rel).components() {
|
||||||
|
match comp {
|
||||||
|
std::path::Component::Normal(_) => {}
|
||||||
|
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vault_relative_paths.insert(child_rel.clone());
|
vault_relative_paths.insert(child_rel.clone());
|
||||||
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
||||||
|
|
||||||
|
if !local_child_path.starts_with(local_target_base) {
|
||||||
|
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
|
||||||
|
}
|
||||||
|
|
||||||
if child.is_dir {
|
if child.is_dir {
|
||||||
if !options.dry_run {
|
if !options.dry_run {
|
||||||
fs::create_dir_all(&local_child_path)?;
|
fs::create_dir_all(&local_child_path)?;
|
||||||
|
|||||||
+98
-29
@@ -1,4 +1,4 @@
|
|||||||
use std::fs::{self, File};
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -16,7 +16,10 @@ pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub const TARGET_ASSET_NAME: &str = "sanctum";
|
pub const TARGET_ASSET_NAME: &str = "sanctum";
|
||||||
|
|
||||||
|
pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
|
||||||
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
|
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||||
|
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||||
|
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
|
||||||
|
|
||||||
/// Repräsentiert ein Asset in einem Gitea-Release
|
/// Repräsentiert ein Asset in einem Gitea-Release
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -46,6 +49,7 @@ pub struct UpgradeOptions {
|
|||||||
pub yes: bool,
|
pub yes: bool,
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
pub insecure_url: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UpgradeOptions {
|
impl Default for UpgradeOptions {
|
||||||
@@ -54,11 +58,28 @@ impl Default for UpgradeOptions {
|
|||||||
check_only: false,
|
check_only: false,
|
||||||
yes: false,
|
yes: false,
|
||||||
force: false,
|
force: false,
|
||||||
base_url: "https://gitea.pansi.eu".to_string(),
|
base_url: OFFICIAL_UPGRADE_URL.to_string(),
|
||||||
|
insecure_url: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
|
||||||
|
pub fn verify_minisign_signature(
|
||||||
|
data: &[u8],
|
||||||
|
sig_str: &str,
|
||||||
|
pubkey_str: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
||||||
|
let signature = minisign_verify::Signature::decode(sig_str)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
||||||
|
pubkey
|
||||||
|
.verify(data, &signature, true)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Erkannter Paketmanager
|
/// Erkannter Paketmanager
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PackageManager {
|
pub enum PackageManager {
|
||||||
@@ -198,19 +219,11 @@ pub fn download_and_verify_binary(
|
|||||||
|
|
||||||
let mut reader = resp.into_reader();
|
let mut reader = resp.into_reader();
|
||||||
|
|
||||||
let temp_dir = std::env::temp_dir();
|
let mut temp_file = tempfile::Builder::new()
|
||||||
let temp_file_path = temp_dir.join(format!(
|
.prefix("sanctum_update_")
|
||||||
"sanctum_update_{}_{}",
|
.suffix(if cfg!(windows) { ".exe" } else { "" })
|
||||||
std::process::id(),
|
.tempfile()
|
||||||
TARGET_ASSET_NAME
|
.context("Konnte temporäre Update-Datei nicht sicher erstellen")?;
|
||||||
));
|
|
||||||
|
|
||||||
let mut temp_file = File::create(&temp_file_path).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Konnte temporäre Update-Datei '{}' nicht erstellen",
|
|
||||||
temp_file_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
|
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
|
||||||
@@ -229,6 +242,7 @@ pub fn download_and_verify_binary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
temp_file
|
temp_file
|
||||||
|
.as_file_mut()
|
||||||
.write_all(&buffer[..n])
|
.write_all(&buffer[..n])
|
||||||
.context("Fehler beim Schreiben in die temporäre Datei")?;
|
.context("Fehler beim Schreiben in die temporäre Datei")?;
|
||||||
hasher.update(&buffer[..n]);
|
hasher.update(&buffer[..n]);
|
||||||
@@ -254,16 +268,16 @@ pub fn download_and_verify_binary(
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
temp_file
|
temp_file
|
||||||
|
.as_file_mut()
|
||||||
.flush()
|
.flush()
|
||||||
.context("Fehler beim Flush der temporären Datei")?;
|
.context("Fehler beim Flush der temporären Datei")?;
|
||||||
drop(temp_file);
|
|
||||||
|
|
||||||
// SHA-256 Integritätsprüfung
|
// SHA-256 Integritätsprüfung
|
||||||
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
|
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
|
||||||
let expected_lower = expected_hash.to_lowercase();
|
let expected_lower = expected_hash.to_lowercase();
|
||||||
|
|
||||||
if calculated_hash != expected_lower {
|
if calculated_hash != expected_lower {
|
||||||
let _ = fs::remove_file(&temp_file_path);
|
// temp_file wird beim Drop automatisch gelöscht
|
||||||
bail!(
|
bail!(
|
||||||
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
|
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
|
||||||
expected_lower,
|
expected_lower,
|
||||||
@@ -273,7 +287,11 @@ pub fn download_and_verify_binary(
|
|||||||
|
|
||||||
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
|
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
|
||||||
|
|
||||||
Ok(temp_file_path)
|
let (_, path) = temp_file
|
||||||
|
.keep()
|
||||||
|
.context("Konnte temporäre Datei nicht für Ersetzung persistieren")?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt den In-Place-Austausch des laufenden Binaries durch.
|
/// Führt den In-Place-Austausch des laufenden Binaries durch.
|
||||||
@@ -284,19 +302,18 @@ pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mut perms = fs::metadata(temp_binary)
|
if let Ok(meta) = fs::metadata(temp_binary) {
|
||||||
.context("Konnte Metadaten der Update-Datei nicht lesen")?
|
let mut perms = meta.permissions();
|
||||||
.permissions();
|
perms.set_mode(0o755);
|
||||||
perms.set_mode(0o755);
|
let _ = fs::set_permissions(temp_binary, perms);
|
||||||
fs::set_permissions(temp_binary, perms)
|
}
|
||||||
.context("Konnte Ausführungsrechte (chmod +x / 0755) nicht setzen")?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self_replace::self_replace(temp_binary)
|
let replace_res = self_replace::self_replace(temp_binary);
|
||||||
.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
|
|
||||||
|
|
||||||
let _ = fs::remove_file(temp_binary);
|
let _ = fs::remove_file(temp_binary);
|
||||||
|
|
||||||
|
replace_res.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
|
||||||
|
|
||||||
Ok(current_exe)
|
Ok(current_exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +328,37 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
println!(" • Server: {}", options.base_url);
|
println!(" • Server: {}", options.base_url);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
// S-02: Prüfung auf abweichende Server-URL
|
||||||
|
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
|
||||||
|
if !is_official_server {
|
||||||
|
if !options.insecure_url {
|
||||||
|
bail!(
|
||||||
|
"Sicherheitsverstoß: Abweichende Server-URL '{}' ist nicht vertrauenswürdig.\n\
|
||||||
|
Aus Sicherheitsgründen sind Drittanbieter-URLs standardmäßig gesperrt.\n\
|
||||||
|
Falls beabsichtigt, verwenden Sie das Flag '--insecure-url'.",
|
||||||
|
options.base_url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch mit --insecure-url ist zwingend eine manuelle Bestätigung erforderlich (nicht mit -y überschreibbar!)
|
||||||
|
println!(
|
||||||
|
" {} WARNUNG: Sie aktualisieren von einem inoffiziellen Server: {}",
|
||||||
|
ui::red("[SICHERHEITSWARNUNG]"),
|
||||||
|
options.base_url
|
||||||
|
);
|
||||||
|
println!(" Offizieller Server ist: {}", OFFICIAL_UPGRADE_URL);
|
||||||
|
print!(" Möchten Sie dieses ungesicherte Update WIRKLICH fortsetzen? [Tippen Sie 'JA' zur Bestätigung]: ");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
let mut confirm = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.read_line(&mut confirm)
|
||||||
|
.context("Fehler beim Einlesen der Bestätigung")?;
|
||||||
|
if confirm.trim() != "JA" {
|
||||||
|
bail!("Upgrade von Drittanbieter-URL durch Benutzer abgebrochen.");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Paketmanager-Prüfung
|
// 1. Paketmanager-Prüfung
|
||||||
if let Some(pm) = detect_package_manager() {
|
if let Some(pm) = detect_package_manager() {
|
||||||
println!(
|
println!(
|
||||||
@@ -373,7 +421,7 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Ziel-Asset und Checksumme identifizieren
|
// 3. Ziel-Asset, Checksumme und Minisign-Signatur identifizieren
|
||||||
let target_asset = release
|
let target_asset = release
|
||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -398,9 +446,30 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 4. Prüfsummen herunterladen und extrahieren
|
let sig_asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_SIG_ASSET_NAME))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Im Release '{}' wurde keine Minisign-Signatur ('{}') gefunden! Unsignierte Updates werden verweigert.",
|
||||||
|
remote_tag,
|
||||||
|
CHECKSUM_SIG_ASSET_NAME
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
|
||||||
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
|
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
|
||||||
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
|
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
|
||||||
|
|
||||||
|
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
|
||||||
|
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
|
||||||
|
|
||||||
|
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
|
||||||
|
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
|
||||||
|
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
|
||||||
|
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
|
||||||
|
|
||||||
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||||
|
|||||||
+4
-4
@@ -310,8 +310,8 @@ mod tests {
|
|||||||
let password = "HealthyTestPassword123!";
|
let password = "HealthyTestPassword123!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -362,8 +362,8 @@ mod tests {
|
|||||||
let password = "BitrotTestPassword123!";
|
let password = "BitrotTestPassword123!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
+3
-3
@@ -1052,11 +1052,11 @@ mod tests {
|
|||||||
|
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek("testpwd", &salt, &kdf_params).unwrap();
|
let kek = derive_kek("TestMasterPassword123!", &salt, &kdf_params).unwrap();
|
||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use rand::RngCore;
|
|||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
||||||
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
|
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::verify::verify_container;
|
use sanctum::verify::verify_container;
|
||||||
@@ -31,8 +31,8 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
|||||||
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,8 +270,8 @@ async fn test_model_a_container_file_size_invariance() {
|
|||||||
let pass_hidden = "HiddenPass2026!";
|
let pass_hidden = "HiddenPass2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,8 +369,8 @@ async fn test_carrier_file_drop_and_append_mode() {
|
|||||||
let pass_hidden = "HiddenSecretPassword2026!";
|
let pass_hidden = "HiddenSecretPassword2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+21
-20
@@ -11,6 +11,7 @@ use sanctum::{
|
|||||||
crypto::{
|
crypto::{
|
||||||
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
|
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
|
||||||
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
||||||
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
},
|
},
|
||||||
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
||||||
storage::Database,
|
storage::Database,
|
||||||
@@ -34,8 +35,8 @@ async fn test_sanctum_full_container_lifecycle() {
|
|||||||
// 1. Initialisierung
|
// 1. Initialisierung
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024, // Schnell für Tests
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||||
@@ -176,8 +177,8 @@ async fn test_sanctum_password_change() {
|
|||||||
// 1. Initialisierung mit Passwort v1
|
// 1. Initialisierung mit Passwort v1
|
||||||
let salt_v1 = generate_salt();
|
let salt_v1 = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
|
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
|
||||||
@@ -262,8 +263,8 @@ async fn test_sanctum_lz4_compression_efficiency() {
|
|||||||
let password = "CompressTestPassword2026!";
|
let password = "CompressTestPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -333,8 +334,8 @@ async fn test_sanctum_v1_backward_compatibility() {
|
|||||||
let password = "V1LegacyPassword!";
|
let password = "V1LegacyPassword!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -392,8 +393,8 @@ async fn test_sanctum_disaster_recovery_workflow() {
|
|||||||
let initial_password = "PrimaryPassword2026!";
|
let initial_password = "PrimaryPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -511,8 +512,8 @@ async fn test_anti_leak_and_inactivity_shield() {
|
|||||||
let password = "ShieldedContainerPass2026!";
|
let password = "ShieldedContainerPass2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
|
||||||
@@ -625,8 +626,8 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
|
|||||||
let salt0 = generate_salt();
|
let salt0 = generate_salt();
|
||||||
let salt1 = generate_salt();
|
let salt1 = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -812,8 +813,8 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards()
|
|||||||
let pass_decoy = "OuterDecoyPassphrase2026!";
|
let pass_decoy = "OuterDecoyPassphrase2026!";
|
||||||
let pass_hidden = "InnerHiddenVaultPass2026!";
|
let pass_hidden = "InnerHiddenVaultPass2026!";
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -990,8 +991,8 @@ async fn test_sanctum_online_backup_and_restore() {
|
|||||||
let password = "BackupTestPassword2026!";
|
let password = "BackupTestPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -1079,8 +1080,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
let password = "WebDavSecPassword2026!";
|
let password = "WebDavSecPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ async fn test_live_crash_and_recovery_stress() {
|
|||||||
let password = "LiveStressPassword2026!";
|
let password = "LiveStressPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: sanctum::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||||
|
MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::mount::is_loopback_host;
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_loopback_host_comprehensive() {
|
||||||
|
// Gültige IPv4 Loopback Varianten
|
||||||
|
assert!(is_loopback_host("127.0.0.1"));
|
||||||
|
assert!(is_loopback_host("127.0.0.1:80"));
|
||||||
|
assert!(is_loopback_host("127.0.0.1:8080"));
|
||||||
|
assert!(is_loopback_host("127.0.0.1:65535"));
|
||||||
|
|
||||||
|
// Gültige Hostname Loopback Varianten
|
||||||
|
assert!(is_loopback_host("localhost"));
|
||||||
|
assert!(is_loopback_host("localhost:80"));
|
||||||
|
assert!(is_loopback_host("localhost:8443"));
|
||||||
|
assert!(is_loopback_host("LOCALHOST"));
|
||||||
|
assert!(is_loopback_host("LocalHost:9000"));
|
||||||
|
|
||||||
|
// Gültige IPv6 Loopback Varianten
|
||||||
|
assert!(is_loopback_host("[::1]"));
|
||||||
|
assert!(is_loopback_host("[::1]:80"));
|
||||||
|
assert!(is_loopback_host("[::1]:8443"));
|
||||||
|
assert!(is_loopback_host("::1"));
|
||||||
|
|
||||||
|
// DNS-Rebinding & Spoofing Angriffe (MÜSSEN abgewiesen werden)
|
||||||
|
assert!(!is_loopback_host("127.0.0.1.attacker.com"));
|
||||||
|
assert!(!is_loopback_host("localhost.attacker.com"));
|
||||||
|
assert!(!is_loopback_host("localhost.evil.org:8080"));
|
||||||
|
assert!(!is_loopback_host("notlocalhost"));
|
||||||
|
assert!(!is_loopback_host("attacker.com"));
|
||||||
|
assert!(!is_loopback_host("attacker.com:127001"));
|
||||||
|
assert!(!is_loopback_host("192.168.1.1"));
|
||||||
|
assert!(!is_loopback_host("10.0.0.1"));
|
||||||
|
assert!(!is_loopback_host("0.0.0.0"));
|
||||||
|
assert!(!is_loopback_host(""));
|
||||||
|
assert!(!is_loopback_host(" "));
|
||||||
|
assert!(!is_loopback_host("foo:bar:baz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wal_and_shm_cleanup_on_close() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
|
||||||
|
if container_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("WalCleanupPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&container_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
|
||||||
|
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
|
||||||
|
let _ = db.create_node(1, "test_file.txt", false).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// Bereinigungslogik (wie in mount.rs unmount) ausführen
|
||||||
|
let base_os = container_path.as_os_str().to_os_string();
|
||||||
|
let mut wal = base_os.clone();
|
||||||
|
wal.push("-wal");
|
||||||
|
let _ = std::fs::remove_file(&wal);
|
||||||
|
let mut shm = base_os;
|
||||||
|
shm.push("-shm");
|
||||||
|
let _ = std::fs::remove_file(&shm);
|
||||||
|
|
||||||
|
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
|
||||||
|
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use sanctum::sync::validate_node_name;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_node_name_rejections() {
|
||||||
|
// 1. Leere Namen
|
||||||
|
assert!(validate_node_name("").is_err());
|
||||||
|
|
||||||
|
// 2. Relative Verzeichnisreferenzen
|
||||||
|
assert!(validate_node_name(".").is_err());
|
||||||
|
assert!(validate_node_name("..").is_err());
|
||||||
|
|
||||||
|
// 3. Pfadtrenner
|
||||||
|
assert!(validate_node_name("foo/bar").is_err());
|
||||||
|
assert!(validate_node_name("foo\\bar").is_err());
|
||||||
|
assert!(validate_node_name("../evil.exe").is_err());
|
||||||
|
assert!(validate_node_name("..\\evil.exe").is_err());
|
||||||
|
assert!(validate_node_name("/root_file").is_err());
|
||||||
|
|
||||||
|
// 4. Null-Bytes
|
||||||
|
assert!(validate_node_name("test\0file.txt").is_err());
|
||||||
|
|
||||||
|
// 5. Steuerzeichen (< 0x20)
|
||||||
|
assert!(validate_node_name("line\nbreak.txt").is_err());
|
||||||
|
assert!(validate_node_name("carriage\rreturn.txt").is_err());
|
||||||
|
assert!(validate_node_name("tab\tseparated.txt").is_err());
|
||||||
|
assert!(validate_node_name("control\x01char.txt").is_err());
|
||||||
|
|
||||||
|
// 6. Windows reservierte Gerätenamen
|
||||||
|
let reserved_names = [
|
||||||
|
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
|
||||||
|
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
|
||||||
|
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
|
||||||
|
];
|
||||||
|
for res in reserved_names {
|
||||||
|
assert!(
|
||||||
|
validate_node_name(res).is_err(),
|
||||||
|
"Reservierter Name '{}' muss abgewiesen werden",
|
||||||
|
res
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_node_name_accepted() {
|
||||||
|
let valid_names = [
|
||||||
|
"document.pdf",
|
||||||
|
"my_notes_2026.txt",
|
||||||
|
"archive.tar.gz",
|
||||||
|
"spaces are allowed.docx",
|
||||||
|
"unicode_äöü_ß_test.rs",
|
||||||
|
"hyphens-and_underscores.json",
|
||||||
|
"file (1).jpg",
|
||||||
|
"normal_file",
|
||||||
|
];
|
||||||
|
for valid in valid_names {
|
||||||
|
assert!(
|
||||||
|
validate_node_name(valid).is_ok(),
|
||||||
|
"Gültiger Name '{}' sollte akzeptiert werden",
|
||||||
|
valid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
||||||
|
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_carrier_protection_in_storage_and_sync() {
|
||||||
|
let path = temp_db_path("carrier_sec");
|
||||||
|
let carrier_name = "secret_carrier.iso";
|
||||||
|
let carrier_size_bytes = 2 * 1024 * 1024; // 2 MB
|
||||||
|
|
||||||
|
let pass_decoy = "DecoyPassword2026!";
|
||||||
|
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||||
|
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
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. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
|
||||||
|
let del_res = db.delete_node(carrier_node_id);
|
||||||
|
assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen");
|
||||||
|
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
||||||
|
|
||||||
|
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
|
||||||
|
let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0);
|
||||||
|
assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen");
|
||||||
|
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
||||||
|
|
||||||
|
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
|
||||||
|
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
|
||||||
|
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
|
||||||
|
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
||||||
|
|
||||||
|
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
|
||||||
|
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
||||||
|
let local_paths = HashSet::new();
|
||||||
|
let mut stats = SyncStats::default();
|
||||||
|
let orphan_res = delete_orphans_in_vault(
|
||||||
|
&db,
|
||||||
|
0, // Decoy Vault ID
|
||||||
|
&dek_0,
|
||||||
|
1, // Root-Knoten
|
||||||
|
"",
|
||||||
|
&local_paths,
|
||||||
|
false, // kein dry_run
|
||||||
|
true, // quiet
|
||||||
|
&mut stats,
|
||||||
|
);
|
||||||
|
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
|
||||||
|
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
|
||||||
|
|
||||||
|
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
|
||||||
|
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
|
||||||
|
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
+2
-2
@@ -15,8 +15,8 @@ fn create_test_container(path: &Path) -> (Database, [u8; 32]) {
|
|||||||
let db = Database::open(path).expect("Open database");
|
let db = Database::open(path).expect("Open database");
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: sanctum::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
|
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||||
|
MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_arbitrary_sqlite_database() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_foreign_sqlite_{}.db", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
|
||||||
|
let res = Database::open(&db_path);
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("kein gültiger Sanctum-Container"),
|
||||||
|
"Fehler muss ungültigen Container abweisen: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Fremde SQLite-DB ohne meta-Tabelle muss abgelehnt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_sqlite_database_with_invalid_magic() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_invalid_magic_{}.db", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle SQLite DB mit 'meta' Tabelle, aber falscher Magic
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER PRIMARY KEY,
|
||||||
|
magic BLOB NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL,
|
||||||
|
kdf_params TEXT NOT NULL,
|
||||||
|
wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL,
|
||||||
|
header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
||||||
|
VALUES (0, ?1, 2, ?2, '{}', ?3, ?4, ?5)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"EVIL_MAGIC",
|
||||||
|
&[0u8; 16][..],
|
||||||
|
&[0u8; 40][..],
|
||||||
|
&[0u8; 12][..],
|
||||||
|
&[0u8; 16][..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = Database::open(&db_path);
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("kein gültiger Sanctum-Container"),
|
||||||
|
"Fehler muss ungültigen Magic-Header monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Container mit ungültigen Magic Bytes muss abgelehnt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_out_of_bounds_kdf_params() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_oob_kdf_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initialisiere gültigen Container
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// 2. Manipuliere KDF-Parameter in der meta-Tabelle auf gefährliche Werte (DoS: 16 GiB Memory)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 16777216, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Öffnen und read_slots() aufrufen: muss mit Kdf-Param-Fehler abbrechen
|
||||||
|
let db_reopened = Database::open(&db_path).unwrap();
|
||||||
|
let slots_res = db_reopened.read_slots();
|
||||||
|
match slots_res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("KDF-Parameter"),
|
||||||
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Überhöhte KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Manipuliere KDF-Parameter auf zu schwache Werte (< MIN_MEMORY_COST_KIB)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 1024, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let slots_res2 = db_reopened.read_slots();
|
||||||
|
match slots_res2 {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("KDF-Parameter"),
|
||||||
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Zu schwache KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_corrupted_slot_lengths() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// Manipuliere Salt-Länge auf 8 Byte statt 16 Byte
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_salt = ?1 WHERE slot_id = 0",
|
||||||
|
[&[0u8; 8][..]],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_reopened = Database::open(&db_path).unwrap();
|
||||||
|
let res = db_reopened.read_slots();
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("Salt-Länge"),
|
||||||
|
"Fehler muss fehlerhafte Salt-Länge monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Ungültige Salt-Länge muss abgewiesen werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use sanctum::upgrade::{
|
||||||
|
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_valid() {
|
||||||
|
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_tampered_payload() {
|
||||||
|
let tampered_payload = b"Hello Sanctum Release 0.7.0 Tampered Payload!\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
|
||||||
|
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_wrong_key() {
|
||||||
|
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
// Ein anderer, unautorisierter Public Key
|
||||||
|
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
|
||||||
|
let res = verify_minisign_signature(payload, sig_text, other_key);
|
||||||
|
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
|
||||||
|
let options = UpgradeOptions {
|
||||||
|
check_only: true,
|
||||||
|
yes: false,
|
||||||
|
force: false,
|
||||||
|
base_url: "https://evil-attacker.org/sanctum".into(),
|
||||||
|
insecure_url: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = run_upgrade(&options);
|
||||||
|
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
|
||||||
|
let err_msg = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
|
||||||
|
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user