Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6d54d5ca | ||
|
|
84b6a39f3f | ||
|
|
28a4bb6b70 | ||
|
|
45572b7370 | ||
|
|
17908a64fe | ||
|
|
487f999a24 | ||
|
|
5a1fd7cdd8 | ||
|
|
07afe34ad4 | ||
|
|
bb86447039 | ||
|
|
8dd54494b0 | ||
|
|
78a9c233f8 | ||
|
|
c7c0ed85f5 | ||
|
|
92db21d47e | ||
|
|
15adf79f48 | ||
|
|
1ba67cc9e2 | ||
|
|
708da24d91 | ||
|
|
730393423b | ||
|
|
e300a764c9 | ||
|
|
17ac4f9358 | ||
|
|
a2c16eda72 | ||
|
|
e496c418c5 | ||
|
|
2af14eef36 | ||
|
|
ad9148d867 | ||
|
|
a502845662 | ||
|
|
52859623c2 | ||
|
|
f9733dfc73 | ||
|
|
9634beb744 | ||
|
|
f557a9ddfa | ||
|
|
888c86ca5e | ||
|
|
7b0f061221 | ||
|
|
bdddd812ac | ||
|
|
36a4094336 | ||
|
|
ad531d8393 | ||
|
|
151fdba09d | ||
|
|
533764487a | ||
|
|
374150e5b3 |
@@ -5,6 +5,92 @@ 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.8.0] - 2026-09-19
|
||||||
|
|
||||||
|
### Security Audit Remediation & Format V3 Release
|
||||||
|
Dieses Release implementiert die vollständige Behebung aller 33 identifizierten Schwachstellen und Härtungsanforderungen aus zwei umfassenden Security-Audit-Runden, rüstet das Container-Format auf V3 auf (mit kanonischer Metadaten-Authentifizierung und Generation-gebundenem Chunk-Replay-Schutz) und führt wesentliche Sicherheits- und Notfallfunktionen ein.
|
||||||
|
|
||||||
|
#### Kryptografie & Container-Format V3 (K-01, K-02, K-03, M-01, M-05)
|
||||||
|
- **K-01: Kanonische Metadaten-Authentifizierung (Format V3)**:
|
||||||
|
- Ergänzung einer HMAC-SHA-256 Metadaten-MAC (`metadata_mac`) im Header, kryptografisch abgeleitet aus dem DEK via HKDF-SHA256 (`SANCTUM_META_MAC_V3`).
|
||||||
|
- Verhindert unbemerkte Manipulation von Verzeichnisstrukturen, Knotennamen, Dateigrößen oder Dateitypen. `sanctum verify` erkennt jegliche Abweichung sofort.
|
||||||
|
- Neuer CLI-Befehl `sanctum upgrade-format --path <container>` zur unterbrechungsfreien Migration von V1/V2 auf V3.
|
||||||
|
- **K-02: Chunk-Replay- & Reordering-Schutz (Format V3)**:
|
||||||
|
- Erweiterung des AAD für AES-256-GCM von 16 Bytes auf 24 Bytes: Bindung an `node_id` (8 Bytes), `chunk_index` (8 Bytes) und monotone `generation` (8 Bytes).
|
||||||
|
- Verhindert das Wiedereinspielen älterer gültiger Ciphertexts durch Angreifer mit Container-Dateizugriff.
|
||||||
|
- **K-03: Notfallblatt-Lebenszyklus & `rekey`-Kommando**:
|
||||||
|
- `sanctum passwd` weist nun unmissverständlich darauf hin, dass das 24-Wörter Notfallblatt (BIP-39) weiterhin gültig bleibt.
|
||||||
|
- Neues CLI-Kommando `sanctum rekey`: Generiert einen frischen DEK, verschlüsselt alle Chunks um, schreddert alte Datenblöcke und gibt eine neue 24-Wort Notfallphrase aus.
|
||||||
|
- **M-01: Erhöhung der Argon2id-Standardparameter**:
|
||||||
|
- Neue Container verwenden standardmäßig `m = 256 MiB` (262.144 KiB), `t = 4` Iterationen und `p = 4` Lanes für maximalen Schutz gegen Brute-Force-Angriffe.
|
||||||
|
- **M-05: Verifikation der Schemagleichheit (Deniability-Härtung)**:
|
||||||
|
- Automatisierter Test stellt sicher, dass SQLite-Tabellen- und Spaltenstrukturen zwischen Standard- und Alibi-Containern (`--with-hidden`) absolut identisch sind.
|
||||||
|
|
||||||
|
#### Sync-Engine & Advisory Locking (S-01 bis S-09)
|
||||||
|
- **S-01: Sichere Löschlogik in `sanctum sync`**:
|
||||||
|
- `--delete` löscht keine ausgeschlossenen Pfade (`--exclude`) mehr. Für das Löschen ausgeschlossener Dateien muss explizit `--delete-excluded` übergeben werden (analog zu rsync).
|
||||||
|
- **S-02: Plattformunabhängiger Pfadzusammenbau**:
|
||||||
|
- Robuste Zerlegung und Zusammenfügung relativer Pfade verhindert Path-Traversal auf Windows- und Unix-Systemen.
|
||||||
|
- **S-03: Symlink-Erkennung & Zyklenschutz**:
|
||||||
|
- Symlinks werden beim Sync erkannt, gezählt (`files_skipped_symlinks`) und nicht rekursiv verfolgt, um Endlosschleifen zu verhindern.
|
||||||
|
- **S-04: TOCTOU-Beseitigung bei Dateigrößen**:
|
||||||
|
- Die endgültige Knotengröße wird aus den tatsächlich geschriebenen Bytes (`bytes_written`) ermittelt statt aus vorherigen Metadaten.
|
||||||
|
- **S-05: Sicheres Kürzen auf 0 Bytes**:
|
||||||
|
- Beim Kürzen von Dateien auf 0 Bytes werden ausnahmslos alle Chunks kryptografisch geschreddert und aus der Datenbank entfernt.
|
||||||
|
- **S-06: Container Advisory Lock**:
|
||||||
|
- Verhindert gleichzeitiges Mounten und kollidierende Schreibzugriffe durch `sync`. Lock wird in Metadaten persistiert; Override via `--force`.
|
||||||
|
- **S-07: Überspringen ungültiger Windows-Dateinamen**:
|
||||||
|
- Reservierte Dateinamen (z. B. `aux.txt`, `con.dat`) werden im Sync übersprungen und gezählt (`files_skipped_invalid`), ohne den Vorgang abzubrechen.
|
||||||
|
- **S-08: Präzisierung des Glob- und Pfad-Matchings**:
|
||||||
|
- Vollständig dokumentiertes und gehärtetes Glob-Matching für Ausschlussregeln.
|
||||||
|
- **S-09: Pull-Overwrite-Schutz & Konfliktsicherheit**:
|
||||||
|
- Warnung und Schutzmechanismen vor unbeabsichtigtem Überschreiben lokaler Daten bei `sync pull`.
|
||||||
|
|
||||||
|
#### VFS, Anti-Leak Shield & Carrier (V-01 bis V-09, Z-04)
|
||||||
|
- **V-01: CarrierFs-Dispatch im Carrier-Modus**:
|
||||||
|
- `SanctumFs::copy` delegiert Kopieroperationen im Alibi-/Trägermodus korrekt an das steganografische CarrierFs.
|
||||||
|
- **V-02: Fehlerbehandlung bei fehlenden Chunks**:
|
||||||
|
- Fehlt ein Datenblock innerhalb der erwarteten Dateigröße, wird ein harter E/A-Fehler (`FsError::GeneralFailure`) geworfen statt stiller Datenkürzung.
|
||||||
|
- **V-03: RAII Memory Lock Guard**:
|
||||||
|
- Eigener RAII-Guard im `Arc` stellt sicher, dass gesperrte Speicherseiten (`VirtualLock`) beim Klonen des VFS nicht vorzeitig freigegeben werden.
|
||||||
|
- **V-04: Strukturierter Anti-Leak Shield & Custom Filterliste**:
|
||||||
|
- Kategorisierte Filterung nach exakten Dateinamen, Präfixen (`~$*`, `._*`), Suffixen (`.tmp`, `.temp`, `.crdownload`, `.part`, `~*`), NTFS Alternate Data Streams (`:`) und `.trash*`.
|
||||||
|
- Neues CLI-Flag `sanctum mount --anti-leak-list <FILE>` für anwenderspezifische Filterregeln sowie Statistik über gefilterte Dateien beim Unmount.
|
||||||
|
- **V-05: Robuste Fehlerprotokollierung in `SanctumFile::drop`**:
|
||||||
|
- Flush-Fehler beim Schließen von Dateien werden als `error!` geloggt und Flush wird bei `close` erzwungen.
|
||||||
|
- **V-06: Sparse Writes**:
|
||||||
|
- Lücken zwischen dem bisherigen Dateiende und dem Schreib-Offset werden automatisch mit Null-Bytes aufgefüllt.
|
||||||
|
- **V-07: RFC-4918 konforme Overwrite-Semantik**:
|
||||||
|
- Korrekte Behandlung von Overwrite-Headern und Verzeichnis-Konfliktprüfungen in `copy`.
|
||||||
|
- **V-08: Einheitliche Pfadsicherheit**:
|
||||||
|
- Tabulator-Steuerzeichen (0x09) werden in `validate_path_safety` konsistent abgewiesen.
|
||||||
|
- **V-09: Chunk-Shredding vor Löschung**:
|
||||||
|
- `truncate_chunks_after` schreddert vor dem Löschen alle betroffenen Chunks mit Zufallsdaten.
|
||||||
|
- **Z-04: WebDAV Quota-Report**:
|
||||||
|
- `SanctumFs::get_quota` meldet korrekte Containerbelegung und verfügbaren Host-Speicher.
|
||||||
|
|
||||||
|
#### Upgrade & Signaturprüfung (U-01 bis U-03)
|
||||||
|
- **U-01: Striktes Pre-Hashing für Minisign**:
|
||||||
|
- Minisign-Verifikation akzeptiert ausschließlich pre-gehashte Signaturen (`allow_legacy = false`).
|
||||||
|
- **U-02: Versionsvalidierung im Trusted Comment**:
|
||||||
|
- Validiert, dass die im signierten `trusted comment` hinterlegte Version exakt mit dem Release-Tag übereinstimmt (Replay-Schutz).
|
||||||
|
- **U-03: Sekundärer Backup-Release-Schlüssel**:
|
||||||
|
- Hinterlegung eines sekundären Public Keys für nahtlosen Schlüsselübergang bei Key-Rollover.
|
||||||
|
|
||||||
|
#### Plattform, Speichersicherheit & Dokumentation (M-02, M-03, M-04, Z-01, Z-02, Z-03)
|
||||||
|
- **M-02: Ganzzahl-Überlaufschutz**:
|
||||||
|
- `overflow-checks = true` im Release-Profil zur Abwehr von Integer-Overflows.
|
||||||
|
- **M-03: Linux-Mount-Instruktionen**:
|
||||||
|
- Transparente Ausgabe von WebDAV-Zugangsdaten und manuelle Mount-Befehle unter Linux.
|
||||||
|
- **M-04: Bedrohungsmodell für Session-Tokens**:
|
||||||
|
- Umfassende Dokumentation des Userland-Sicherheitsmodells und Prozessgrenzen in `THREAT_MODEL.md` und `README.md`.
|
||||||
|
- **Z-01: Sicheres Decoy-Passwort-Zeroizing**:
|
||||||
|
- Schutz des temporären Alibi-Passworts im Speicher via `Zeroizing<String>`.
|
||||||
|
- **Z-02: Mutex-Poisoning-Resilienz**:
|
||||||
|
- Automatische Erholung von vergifteten Mutexes bei Thread-Panics in SQLite-Verbindungen.
|
||||||
|
- **Z-03: WAL- und SHM-Forensik-Dokumentation**:
|
||||||
|
- Dokumentation über die flüchtige Existenz verschlüsselter WAL-/SHM-Dateien und Schredder-Mechanismen.
|
||||||
|
|
||||||
## [0.7.2] - 2026-09-18
|
## [0.7.2] - 2026-09-18
|
||||||
|
|
||||||
### Security Audit Remediation Release (Findings SA-01 to SA-07)
|
### Security Audit Remediation Release (Findings SA-01 to SA-07)
|
||||||
|
|||||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.2"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.2"
|
version = "0.8.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"
|
||||||
@@ -54,3 +54,18 @@ lto = true
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "unwind"
|
panic = "unwind"
|
||||||
strip = true
|
strip = true
|
||||||
|
overflow-checks = true
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
too_many_arguments = "allow"
|
||||||
|
type_complexity = "allow"
|
||||||
|
field_reassign_with_default = "allow"
|
||||||
|
upper_case_acronyms = "allow"
|
||||||
|
collapsible_if = "allow"
|
||||||
|
manual_dangling_ptr = "allow"
|
||||||
|
needless_borrow = "allow"
|
||||||
|
manual_strip = "allow"
|
||||||
|
redundant_pattern_matching = "allow"
|
||||||
|
needless_range_loop = "allow"
|
||||||
|
manual_range_contains = "allow"
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
|
|||||||
### B. Eigenverantwortung für Backups & Notfallschlüssel
|
### B. Eigenverantwortung für Backups & Notfallschlüssel
|
||||||
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
|
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
|
||||||
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
|
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
|
||||||
|
* **Gültigkeit von Notfallkarten bei Passwortänderung**: Ein normales Ändern des Master-Passworts (`sanctum passwd`) ändert lediglich den KEK und belässt den Datenschlüssel (DEK) unverändert; bestehende Notfallkarten bleiben somit vollumfänglich funktionsfähig. Zur Entwertung kompromittierter Notfallkarten und vollständigen Neugenerierung des Datenschlüssels muss explizit der Befehl `sanctum rekey` ausgeführt werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -73,7 +73,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode
|
|||||||
```powershell
|
```powershell
|
||||||
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
|
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
|
||||||
```
|
```
|
||||||
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
|
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort. Hinweis: Das Notfallblatt bleibt danach weiterhin gültig!).*
|
||||||
|
|
||||||
|
* **Notfallkarte verloren oder kompromittiert? Schlüssel erneuern (Rekeying)**:
|
||||||
|
```powershell
|
||||||
|
sanctum.exe rekey --path "D:\Tresor\daten.sanctum"
|
||||||
|
```
|
||||||
|
*(Generiert einen frischen Verschlüsselungsschlüssel, verschlüsselt alle Chunks um und entwertet das bisherige Notfallblatt unwiderruflich. Gibt eine neue 24-Wort Notfallkarte aus).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ Der Container besteht aus exakt **einer** Host-Datei (`.sanctum`), die dynamisch
|
|||||||
Chunks werden vor der Verschlüsselung via `lz4_flex` komprimiert. Spart die Kompression weniger als 64 Bytes (z. B. bei bereits komprimierten Bildern oder Videos), wird adaptiv die Rohform verschlüsselt.
|
Chunks werden vor der Verschlüsselung via `lz4_flex` komprimiert. Spart die Kompression weniger als 64 Bytes (z. B. bei bereits komprimierten Bildern oder Videos), wird adaptiv die Rohform verschlüsselt.
|
||||||
- **Speicherplatzrückgabe**:
|
- **Speicherplatzrückgabe**:
|
||||||
Durch `PRAGMA auto_vacuum = INCREMENTAL;` können freigewordene SQLite-Pages beim Aushängen oder via `sanctum compact` vollständig an das Windows-Hostdateisystem zurückgegeben werden.
|
Durch `PRAGMA auto_vacuum = INCREMENTAL;` können freigewordene SQLite-Pages beim Aushängen oder via `sanctum compact` vollständig an das Windows-Hostdateisystem zurückgegeben werden.
|
||||||
|
- **SQLite WAL & SHM Begleitdateien (Forensik & Ciphertext-Garantie, Z-03)**:
|
||||||
|
Während des Betriebs erzeugt SQLite temporär `<container>-wal` und `<container>-shm`. Sämtliche Chunks und Inodes werden **vor** dem Schreiben im Userland via AES-256-GCM verschlüsselt, sodass Begleitdateien zu 100% ausschließlich unknackbaren Ciphertext enthalten. Beim regulären Aushängen werden alle Transaktionen via `wal_checkpoint(TRUNCATE)` in den Hauptcontainer überführt und die Begleitdateien restlos entfernt (Details siehe [THREAT_MODEL.md](THREAT_MODEL.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,6 +110,10 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
|
|||||||
```
|
```
|
||||||
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
|
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
|
||||||
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
|
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
|
||||||
|
- **Linux & WebDAV-Integration**: Auf Linux startet Sanctum das WebDAV-VFS auf `127.0.0.1` mit lokaler Session-Token-Authentifizierung. Im Terminal werden direkt die Zugangsdaten und passende Mount-Befehle ausgegeben:
|
||||||
|
- **Benutzer**: `sanctum` | **Passwort**: Dynamisches Session-Token (Hex)
|
||||||
|
- `gio mount dav://sanctum@127.0.0.1:8443/`
|
||||||
|
- `mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/sanctum`
|
||||||
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
|
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
|
||||||
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
|
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
|
||||||
|
|
||||||
@@ -121,12 +127,21 @@ sanctum.exe unmount --drive S
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Master-Passwort ändern
|
### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
|
||||||
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
|
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
|
||||||
```
|
```
|
||||||
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen.
|
> [!NOTE]
|
||||||
|
> `passwd` verpackt den bestehenden Datenschlüssel (DEK) mit einem neuen KEK. Das zuvor notierte **24-Wort Notfallblatt bleibt weiterhin gültig**!
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Master-Schlüssel erneuern & bisherige Notfallkarten entwerten (K-03):
|
||||||
|
sanctum.exe rekey --path "C:\Pfad\tresor.sanctum"
|
||||||
|
```
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> `rekey` generiert einen komplett frischen DEK, verschlüsselt alle Datenblöcke um und gibt ein **neues 24-Wort Notfallblatt** aus. Alle bisherigen Notfallkarten werden dadurch **unwiderruflich ungültig**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,9 +198,12 @@ sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Down
|
|||||||
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
|
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
|
||||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
|
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
|
||||||
|
|
||||||
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel):
|
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel, schont aber per --exclude ausgeschlossene Pfade):
|
||||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
|
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
|
||||||
|
|
||||||
|
# 3b. Radikales Löschen inklusive ausgeschlossener Pfade:
|
||||||
|
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete --delete-excluded
|
||||||
|
|
||||||
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
|
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
|
||||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
|
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
|
||||||
|
|
||||||
@@ -291,6 +309,7 @@ Erzeugt:
|
|||||||
## 📄 Lizenz & Changelog
|
## 📄 Lizenz & Changelog
|
||||||
|
|
||||||
- Lizenziert unter der [MIT License](LICENSE).
|
- Lizenziert unter der [MIT License](LICENSE).
|
||||||
|
- Das detaillierte Bedrohungsmodell und Sicherheitsarchitektur-Dokumentation findest du in [`THREAT_MODEL.md`](THREAT_MODEL.md).
|
||||||
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
|
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
|
||||||
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
|
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
|
||||||
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
|
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Sanctum Security Audit & Remediation Log (v0.8.0)
|
||||||
|
|
||||||
|
Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit-Zusammenfassung
|
||||||
|
|
||||||
|
- **Zielversion:** Sanctum v0.8.0
|
||||||
|
- **Behobene Findings:** 33 / 33 (100%)
|
||||||
|
- **Test-Ergebnis:** 117 / 117 Tests erfolgreich (100% Pass Rate)
|
||||||
|
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
|
||||||
|
- **Container-Format:** Upgrade auf Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersicht aller Findings
|
||||||
|
|
||||||
|
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **S-01** | HOCH | `sync` | `--delete` löscht keine ausgeschlossenen Pfade mehr; Schutz ganzer Verzeichnisbäume; `--delete-excluded` eingeführt | `374150e` | ✅ Bestanden (`test_s01_delete_preserves_excluded_files_and_directories`, `test_s01_pull_delete_preserves_local_excluded_and_leak_files`) |
|
||||||
|
| **V-02** | HOCH | `vfs` | Fehlender Chunk innerhalb erwarteter Dateigröße löst E/A-Fehler aus statt stiller Dateikürzung | `5337644` | ✅ Bestanden (`test_v02_missing_chunk_returns_error_instead_of_silent_truncation`) |
|
||||||
|
| **V-01** | HOCH | `vfs`, `carrier` | `copy()` delegiert im Carrier-Modus korrekt an `CarrierFs` | `151fdba` | ✅ Bestanden (`test_v01_carrier_fs_copy_delegation_and_functionality`) |
|
||||||
|
| **K-01** | HOCH | `crypto`, `storage`, `verify` | Format V3: Kanonische Metadaten-Authentifizierung via HMAC-SHA256 über alle Inodes | `ad531d8` | ✅ Bestanden (`test_k01_metadata_tampering_detected_by_verify`, `test_k01_upgrade_format_v2_to_v3`) |
|
||||||
|
| **K-02** | HOCH | `crypto`, `storage` | Format V3: Chunk-Replay-Schutz mittels 24-Byte AAD (inkl. monotoner Generation) | `36a4094` | ✅ Bestanden (`test_k02_chunk_replay_protection_with_generation_aad`, `test_k02_chunk_replay_detected_by_vfs_and_crypto`) |
|
||||||
|
| **K-03** | HOCH | `crypto`, `recovery`, `cli` | Notfallblatt bleibt bei `passwd` dauerhaft gültig; `sanctum rekey` zur Schlüsselrotation | `bdddd81` | ✅ Bestanden (`test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet`) |
|
||||||
|
| **S-02** | MITTEL | `sync` | Plattformunabhängiger Pfad-Zusammenbau und Traversal-Schutz | `7b0f061` | ✅ Bestanden (`test_s02_platform_independent_path_construction_and_traversal_rejection`) |
|
||||||
|
| **S-03** | MITTEL | `sync` | Symlink-Erkennung, Zählung und Zyklenschutz | `888c86c` | ✅ Bestanden (`test_s03_symlink_skipping_and_cycle_protection`) |
|
||||||
|
| **S-04** | MITTEL | `sync` | Beseitigung von TOCTOU bei Dateigrößen durch Verwendung von `bytes_written` | `f557a9d` | ✅ Bestanden (`test_s04_toctou_file_size_uses_bytes_written`) |
|
||||||
|
| **S-05** | MITTEL | `sync` | Vollständiges Löschen und Schreddern aller Chunks beim Kürzen auf 0 Bytes | `9634beb` | ✅ Bestanden (`test_s05_zero_byte_file_truncate_removes_all_chunks`) |
|
||||||
|
| **S-06** | MITTEL | `storage`, `mount`, `sync` | Advisory Lock in Metadaten verhindert parallelen Mount & gleichzeitige Schreibzugriffe | `f9733df` | ✅ Bestanden (`test_s06_advisory_lock_blocks_sync_and_force_overrides`) |
|
||||||
|
| **V-03** | MITTEL | `vfs`, `windows` | RAII Memory Lock Guard verhindert vorzeitiges Freigeben gesperrter Speicherseiten beim Klonen | `5285962` | ✅ Bestanden (`test_vfs_memory_lock_retention_on_clone_v03`) |
|
||||||
|
| **M-01** | MITTEL | `crypto` | Erhöhung der Argon2id Standardparameter auf 256 MiB RAM und 4 Iterationen | `a502845` | ✅ Bestanden (`test_m01_default_kdf_params_256mib_4_iterations`) |
|
||||||
|
| **M-02** | MITTEL | `build` | Aktivierung von `overflow-checks = true` im Release-Profil | `ad9148d` | ✅ Bestanden (`test_m02_release_profile_enables_overflow_checks`) |
|
||||||
|
| **M-03** | MITTEL | `mount`, `doc` | Ausgabe von WebDAV-Zugangsdaten und Dokumentation manueller Mount-Befehle unter Linux | `2af14ee` | ✅ Bestanden (`test_m03_format_linux_mount_instructions`) |
|
||||||
|
| **M-04** | MITTEL | `doc`, `threat_model` | Dokumentation des Bedrohungsmodells für Session-Tokens im Nutzerkontext | `e496c41` | ✅ Bestanden (Dokumentiert in `README.md`, `THREAT_MODEL.md`) |
|
||||||
|
| **M-05** | MITTEL | `crypto`, `storage` | Verifikation der Schemagleichheit zwischen Standard- und Alibi-/Carrier-Containern | `a2c16ed` | ✅ Bestanden (`test_m05_deniability_schema_equality_standard_vs_hidden`) |
|
||||||
|
| **V-05** | NIEDRIG | `vfs` | Protokollierung von Flush-Fehlern als `error!` in `SanctumFile::drop` und zwingender Flush bei `close` | `17ac4f9` | ✅ Bestanden (`test_v05_sanctum_file_drop_flushes_dirty_chunk_automatically`) |
|
||||||
|
| **V-06** | NIEDRIG | `vfs` | Sparse Writes: Null-Auffüllung von Lücken zwischen Dateiende und Seek-Offset | `e300a76` | ✅ Bestanden (`test_v06_sparse_write_zero_fills_gap`) |
|
||||||
|
| **V-07** | NIEDRIG | `vfs` | RFC-4918 konforme Overwrite-Semantik und Verzeichnis-Konfliktprüfungen in `copy` | `7303934` | ✅ Bestanden (`test_v07_copy_overwrite_existing_file_semantics`) |
|
||||||
|
| **V-08** | NIEDRIG | `vfs`, `pathutil` | Disallow Tabulator-Steuerzeichen (0x09) in `validate_path_safety` | `708da24` | ✅ Bestanden (`test_path_safety_rejects_null_bytes_and_control_chars`) |
|
||||||
|
| **V-09** | NIEDRIG | `storage` | Kryptografisches Schreddern von Datenblöcken vor physischer Chunk-Löschung in `truncate_chunks_after` | `1ba67cc` | ✅ Bestanden (`test_v09_truncate_chunks_after_shreds_before_deletion`) |
|
||||||
|
| **S-07** | NIEDRIG | `sync` | Überspringen ungültiger Windows-Dateinamen (`files_skipped_invalid`) ohne Sync-Abbruch | `15adf79` | ✅ Bestanden (`test_s07_skip_invalid_windows_filenames_push_and_pull`) |
|
||||||
|
| **S-08** | NIEDRIG | `sync` | Präzisierung und Dokumentation des Glob- und Pfad-Matchings | `92db21d` | ✅ Bestanden (`test_s08_refined_glob_and_directory_exclusions`) |
|
||||||
|
| **S-09** | NIEDRIG | `sync` | Schutz vor unbeabsichtigtem Überschreiben bei `sync pull` | `c7c0ed8` | ✅ Bestanden (`test_s09_pull_backup_and_update_conflict_safety`) |
|
||||||
|
| **U-01** | NIEDRIG | `upgrade` | Erzwingen von Pre-Hashing (`allow_legacy = false`) bei Minisign-Verifikation | `78a9c23` | ✅ Bestanden (`test_u01_reject_legacy_unhashed_signature`) |
|
||||||
|
| **U-02** | NIEDRIG | `upgrade` | Validierung der Release-Version im Minisign `trusted comment` gegen den Release-Tag | `8dd5449` | ✅ Bestanden (`test_u02_validate_trusted_comment_version_matching`, `test_u02_reject_mismatched_release_tag_replay`) |
|
||||||
|
| **U-03** | NIEDRIG | `upgrade` | Konfiguration eines sekundären Backup-Release-Public-Keys | `bb86447` | ✅ Bestanden (`test_u03_verify_signature_with_backup_key_fallback`, `test_u03_verify_signature_rejects_untrusted_third_party_key`) |
|
||||||
|
| **Z-01** | NIEDRIG | `mount` | Schutz des Alibi-/Decoy-Passworts im Speicher via `Zeroizing<String>` | `07afe34` | ✅ Bestanden (`test_z01_decoy_password_zeroize_memory`) |
|
||||||
|
| **Z-02** | NIEDRIG | `storage` | Robuste Wiederherstellung bei vergiftetem SQLite-Mutex (`PoisonError`) | `5a1fd7c` | ✅ Bestanden (`test_z02_poisoned_sqlite_mutex_recovery`) |
|
||||||
|
| **Z-03** | NIEDRIG | `mount`, `doc` | Dokumentation der Forensik-Eigenschaften von temporären WAL- und SHM-Dateien | `487f999` | ✅ Bestanden (`test_wal_and_shm_cleanup_on_close`) |
|
||||||
|
| **Z-04** | NIEDRIG | `vfs` | Implementierung des WebDAV Quota-Reports (`DAV:quota-available-bytes`, `DAV:quota-used-bytes`) | `17908a6` | ✅ Bestanden (`test_z04_webdav_quota_report`) |
|
||||||
|
| **V-04** | NIEDRIG | `vfs`, `carrier`, `mount` | Strukturierter Anti-Leak Shield (Exakt, Präfixe, Suffixe, ADS, `.trash*`) & benutzerdefinierte Filterliste | `45572b7` | ✅ Bestanden (`test_is_leak_file`, `test_v04_custom_anti_leak_rules_and_loader`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detaillierte Beschreibungen der Kernarchitektur-Härtungen
|
||||||
|
|
||||||
|
### 1. Container-Format V3 (K-01 & K-02)
|
||||||
|
- **Format-Version:** Neue Container werden mit Format V3 (`format_version = 3`) erzeugt.
|
||||||
|
- **Kanonische Metadaten-Authentifizierung (K-01):**
|
||||||
|
- Alle Inodes des Containers werden kanonisch serialisiert (`id`, `parent_id`, `name`, `is_dir`, `size`, `created_at`, `modified_at`, `is_carrier`, `chunk_count`).
|
||||||
|
- Eine HMAC-SHA-256 MAC wird über diese Struktur berechnet. Der MAC-Schlüssel wird deterministisch per HKDF-SHA256 aus dem DEK abgeleitet (`SANCTUM_META_MAC_V3`).
|
||||||
|
- Der MAC-Wert und ein monotoner Zähler (`metadata_gen`) werden in der Tabelle `meta` abgelegt.
|
||||||
|
- Jede Modifikation aktualisiert die MAC. `sanctum verify` detektiert jegliche unbefugte Manipulation an Metadaten sofort.
|
||||||
|
- Bestehende V1/V2 Container können mit `sanctum upgrade-format --path <container>` unterbrechungsfrei auf V3 migriert werden.
|
||||||
|
- **Chunk-Replay-Schutz (K-02):**
|
||||||
|
- Jeder Chunk besitzt nun ein Attribut `generation` in der Tabelle `chunks`.
|
||||||
|
- Bei jeder Überschreibung eines Datenblocks wird die Generation inkrementiert.
|
||||||
|
- Das Additional Authenticated Data (AAD) für AES-256-GCM wurde für V3 von 16 Bytes auf 24 Bytes erweitert (`node_id || chunk_index || generation`).
|
||||||
|
- Ein Wiedereinspielen eines älteren gültigen Ciphertexts führt unweigerlich zu einem Authentifizierungsfehler (`AEAD decrypt error`).
|
||||||
|
|
||||||
|
### 2. Notfallblatt-Management & Rekeying (K-03)
|
||||||
|
- Die BIP-39 Notfallkarte kodiert direkt den Master-Verschlüsselungsschlüssel (DEK). Bei einer Passwortänderung (`sanctum passwd`) bleibt der DEK intakt, sodass das ausgedruckte Notfallblatt unverändert gültig bleibt.
|
||||||
|
- Um ein kompromittiertes Notfallblatt zu invalidieren, wurde das Kommando `sanctum rekey` eingeführt:
|
||||||
|
- Generiert einen frischen DEK.
|
||||||
|
- Entschlüsselt alle Chunks und verschlüsselt sie mit dem neuen DEK (und frischer Generation).
|
||||||
|
- Schreddert die alten Chunks unwiderruflich im Freispeicher.
|
||||||
|
- Generiert eine neue 24-Wort Notfallphrase für den Nutzer.
|
||||||
|
|
||||||
|
### 3. Anti-Leak Shield Upgrade (V-04)
|
||||||
|
- Vollständige Trennung der Filterregeln in strukturierte Kategorien:
|
||||||
|
- Exakte Namen: `thumbs.db`, `desktop.ini`, `folder.jpg`, `.ds_store`, `.directory`, `.fseventsd`, `.spotlight-v100`.
|
||||||
|
- Präfixe: `~$*` (Office-Temporärdateien), `._*` (AppleDouble Resource Forks).
|
||||||
|
- Suffixe: `.tmp`, `.temp`, `.crdownload`, `.part`, `.partial`, `~*` (Editor-Backups).
|
||||||
|
- NTFS Alternate Data Streams (ADS): Alle Zugriffe mit `:` (z. B. `Zone.Identifier`).
|
||||||
|
- Wildcards: `.trash*`.
|
||||||
|
- Option `--anti-leak-list <FILE>` für benutzerdefinierte Ausschlussmuster.
|
||||||
|
- Statistische Zählung aller blockierten Schreib-/Lesezugriffe und transparente Zusammenfassung beim Unmount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikationsnachweis
|
||||||
|
|
||||||
|
Alle 117 Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# Threat Model & Sicherheitsarchitektur von Sanctum
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.8.0** im reinen Userland-Betrieb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Sicherheitsziele (Security Objectives)
|
||||||
|
|
||||||
|
1. **Vertraulichkeit & Integrität ruhender Daten (Data at Rest)**:
|
||||||
|
Containerdaten dürfen ohne Kenntnis des Master-Passworts oder des 24-Wort BIP-39 Notfallschlüssels weder entschlüsselt noch unbemerkt manipuliert oder wiederholt werden (Replay Protection).
|
||||||
|
2. **Reine Userland-Ausführung ohne Kernel-Treiber**:
|
||||||
|
Vermeidung von Angriffsflächen im Betriebssystem-Kernel (kein Dokan, kein WinFsp, kein unsignierter Treiber). Das gemountete Dateisystem läuft vollständig im Benutzerkontext.
|
||||||
|
3. **Schutz des Session-Tokens im lokalen Benutzerkontext**:
|
||||||
|
Das dynamische Session-Token für den lokalen WebDAV-Endpunkt darf weder über Prozesslisten (`argv`), Dateipfade, URL-Query-Parameter noch über Netzwerkinterfaces lecken.
|
||||||
|
4. **Schutz vor forensischen Spuren (OpSec & Anti-Leak)**:
|
||||||
|
Verhinderung von Betriebssystem-Artefakten (`Thumbs.db`, `desktop.ini`, ShellBags, NTFS Alternate Data Streams, Swap/Pagefile-Auslagerung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Bedrohungsmodell & Angreiferprofile
|
||||||
|
|
||||||
|
### 2.1 Im Fokus (In Scope)
|
||||||
|
|
||||||
|
| Angreifer / Bedrohung | Beschreibung | Sanctum-Gegenmaßnahme |
|
||||||
|
|---|---|---|
|
||||||
|
| **Kalter Datenträger-Angriff** | Angreifer hat physischen oder dateibasierten Zugriff auf die `.sanctum`-Datei auf USB-Stick, SSD oder Cloud-Storage. | Argon2id KDF (256 MiB, t=4, p=4), AES-256-GCM mit eindeutiger Chunk-Generation AAD, kanonische HMAC-SHA256 Metadaten-Authentifizierung (Format V3). |
|
||||||
|
| **Böswillige Manipulation / Bitrot** | Gezieltes Verändern von Metadaten oder Chunks im Speicher. | AEAD-Tags auf allen Datenblöcken; `sanctum verify` erkennt jede Modifikation; Schreib- und Leseoperationen verwerfen manipulierte Blöcke sofort (`Fail-Closed`). |
|
||||||
|
| **Replay- & Chunk-Vertauschungsangriffe** | Vertauschen von Chunks zwischen Dateien oder Einspielen alter Versionen. | Kryptografische Bindung aller Chunks an `(node_id, chunk_index, generation)` in den AEAD Additional Authenticated Data (AAD). |
|
||||||
|
| **Lokale Benutzerisolation** | Mehrbenutzersysteme: Andere Standardbenutzer auf demselben Rechner. | Windows- und Unix-Dateirechte; TCP-Bind ausschließlich an Loopback `127.0.0.1`; dynamisches 128-Bit Session-Token. |
|
||||||
|
| **DNS-Rebinding & Browser-Angriffe** | Eine im Browser des Nutzers laufende bösartige Website versucht, über DNS-Rebinding auf `http://localhost:<port>` zuzugreifen. | Strikte Fail-Closed Validierung des `Host`-Headers (`127.0.0.1`, `localhost`, `[::1]`). Abweisung aller externen Hostnamen (RT-02). |
|
||||||
|
| **Argv- & Prozess-Scraping** | Andere Prozesse des gleichen Nutzers oder Monitoring-Tools lesen die Prozess-Kommandozeile aus. | In-Process Netzlaufwerk-Verbindung via `WNetAddConnection2W` (Windows) ohne `net use`-Subprozess. Token wird niemals über `argv` übergeben. |
|
||||||
|
| **URL- & Proxy-Logging (CWE-598)** | Protokollierung von HTTP-Anfragen in lokalen Logs oder WebDAV-Clients. | Strikte Abweisung von Session-Tokens in URL-Pfad oder Query-String (`403 Forbidden`). Tokens dürfen ausschließlich im `Authorization`- oder `X-Sanctum-Token`-Header übertragen werden (SA-05). |
|
||||||
|
| **Timing Side-Channel Angriffe** | Messung von Antwortzeiten beim Token-Vergleich. | Strikter Constant-Time Vergleich (`subtle::ConstantTimeEq`) für alle Authentifizierungsprüfungen (SA-06). |
|
||||||
|
| **RAM-Dump & Pagefile-Forensik** | Windows lagert Arbeitsspeicher in `pagefile.sys` oder `swapfile.sys` aus. | Verriegelung der Schlüssel im physischen RAM via `VirtualLock` (Windows) bzw. `mlock` (Unix) mit RAII-Lebenszeitgarantie (`MemoryLockGuard`, V-03). Sicheres Überschreiben beim Beenden (`zeroize::Zeroizing`). |
|
||||||
|
| **Slowloris DoS auf Loopback** | Ressourcenerschöpfung durch offengehaltene Sockets. | Beschränkung auf max. 64 gleichzeitige Verbindungen (`MAX_CONCURRENT_DAV_CONNECTIONS`) und 15s Header-Read-Timeout (`HTTP_HEADER_READ_TIMEOUT`). |
|
||||||
|
| **Begleitdateien-Forensik (-wal/-shm)** | Angreifer analysiert temporäre SQLite-Dateien während oder nach dem Mount. | Sämtliche Daten werden vor Übergabe an SQLite verschlüsselt; WAL/SHM enthalten ausschließlich Ciphertext; TRUNCATE-Checkpointing und Bereinigung beim Aushängen (Z-03). |
|
||||||
|
| **Nötigung / Schulterblick** | Zwang zur Passwortherausgabe. | Alibi-Carrier (Hidden Vault Modell A) mit steganografisch verstecktem zweiten Tresor und unabhängigen Schlüsseln. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Außerhalb des Fokus (Out of Scope / Annahmen)
|
||||||
|
|
||||||
|
- **Vollständig kompromittierter Host**: Ein Angreifer mit Kernel-Rootkit, Ring-0-Treiberrechten oder administrativem Keylogger kontrolliert das Gesamtsystem. In diesem Fall kann keine Userland-Anwendung Sicherheit garantieren.
|
||||||
|
- **Direkte Speicherinjektion unter demselben Benutzerkonto**: Wenn Malware unter demselben Benutzerkonto mit denselben Rechten läuft und `ReadProcessMemory` / `OpenProcess` ausführt, greift das Betriebssystem-Sicherheitsmodell nicht. Sanctum empfiehlt getrennte Benutzerkonten und die Aktivierung von Windows Defender Exploit Protection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Session-Token Bedrohungsmodell im Detail
|
||||||
|
|
||||||
|
### 3.1 Lebenszyklus und Generierung
|
||||||
|
- Für jede Mount-Sitzung wird ein **frisches 128-Bit kryptografisches Zufallstoken** via `rand::rngs::OsRng` (CryptGenRandom bzw. `getrandom`) generiert.
|
||||||
|
- Das Token existiert ausschließlich im flüchtigen Speicher des Sanctum-Prozesses und wird nach dem Aushängen sicher aus dem RAM genullt.
|
||||||
|
|
||||||
|
### 3.2 Keine Befehlszeilen-Leaks
|
||||||
|
Unter Windows wird das WebDAV-Netzlaufwerk nicht über einen externen Aufruf wie `net use Z: http://127.0.0.1:8443 /user:...` eingebunden, sondern über den direkten Win32-API-Aufruf:
|
||||||
|
```c
|
||||||
|
WNetAddConnection2W(&net_resource, password, username, CONNECT_TEMPORARY)
|
||||||
|
```
|
||||||
|
Dadurch tauchen weder das Token noch die Zugangsdaten in der Windows-Prozesstabelle (`Get-Process`, Task-Manager, Sysinternals Process Explorer) auf.
|
||||||
|
|
||||||
|
### 3.3 Header-basierte Authentifizierung (SA-05 & SA-06)
|
||||||
|
- **Erlaubt**:
|
||||||
|
- `Authorization: Basic <base64(sanctum:token)>`
|
||||||
|
- `X-Sanctum-Token: <token>`
|
||||||
|
- **Streng verboten**: Token im URI-Pfad (`http://127.0.0.1:8443/<token>/...`) oder im Query-String (`?token=<token>`). Werden solche Anfragen empfangen, bricht Sanctum die Verarbeitung sofort mit HTTP `403 Forbidden` ab. Dies verhindert, dass Tokens in Referrer-Headern, Browser-Verläufen oder WebDAV-Caches protokolliert werden.
|
||||||
|
- Alle Vergleiche erfolgen in konstanter Zeit (`ConstantTimeEq`), sodass Angreifer keine Rückschlüsse auf Token-Präfixe über Laufzeitunterschiede ziehen können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Speicher- und Prozessisolation
|
||||||
|
|
||||||
|
1. **VirtualLock & mlock RAII (`MemoryLockGuard`)**:
|
||||||
|
Der 256-Bit Data Encryption Key (DEK) wird sofort nach der Entschlüsselung im RAM mit `VirtualLock` (Windows) bzw. `mlock` (Linux) verriegelt. Dies garantiert, dass der Schlüssel zu keinem Zeitpunkt vom Windows Memory Manager in die unverschlüsselte Auslagerungsdatei (`pagefile.sys` / `swapfile.sys`) ausgelagert wird.
|
||||||
|
Durch die Kapselung in `Arc<MemoryLockGuard>` bleibt dieser Schutz auch bei parallelen WebDAV-Anfragen erhalten und wird erst aufgehoben, wenn die letzte Referenz freigegeben wird (V-03).
|
||||||
|
2. **Zeroizing**:
|
||||||
|
Alle Schlüsselpuffer (`KEK`, `DEK`, KDF-Zwischenergebnisse) implementieren `zeroize::ZeroizeOnDrop` und werden beim Verlassen des Gültigkeitsbereichs mit Nullen überschrieben.
|
||||||
|
3. **Session-Lock & Inactivity Shield**:
|
||||||
|
Sanctum lauscht über `WTSRegisterSessionNotification` auf Sperr-Events (`Win + L`) und Inaktivitäts-Timeouts. Beim Sperren wird das Laufwerk unverzüglich getrennt, alle Caches geleert, WAL-Checkpoints geschrieben und die Schlüssel zerstört.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dateisystem-Forensik & SQLite WAL/SHM Begleitdateien (Z-03)
|
||||||
|
|
||||||
|
### 5.1 Funktionsweise & Ciphertext-Garantie
|
||||||
|
Während ein Sanctum-Container geöffnet oder aktiv eingebunden ist, nutzt die zugrundeliegende SQLite-Engine den Write-Ahead Logging (WAL) Modus (`PRAGMA journal_mode = WAL`). Dadurch entstehen temporär zwei Begleitdateien im selben Verzeichnis wie der Container:
|
||||||
|
- `<container>.sanctum-wal` (Write-Ahead Log für Transaktionsdaten)
|
||||||
|
- `<container>.sanctum-shm` (Shared-Memory-Index für parallele Lese- und Schreibzugriffe)
|
||||||
|
|
||||||
|
**Kryptografische Sicherheit**:
|
||||||
|
Alle Nutzdaten (Chunks) und Inode-Metadaten (Dateinamen) werden **vor** der Übergabe an SQLite im Sanctum-Userland via AES-256-GCM verschlüsselt. Die SQLite-Engine verarbeitet und speichert ausschließlich hochgradig ununterscheidbare Ciphertexte, Nonces, Authentifizierungs-Tags und HMAC-Prüfsummen.
|
||||||
|
- Zu **keinem Zeitpunkt** gelangen Klartextdaten oder kryptografische Schlüssel (KEK, DEK) in die `-wal`- oder `-shm`-Dateien.
|
||||||
|
- Selbst bei forensischer Extraktion der `-wal`- und `-shm`-Dateien sieht ein Angreifer ausschließlich unknackbares Zufallsrauschen ohne Entschlüsselungsmöglichkeit.
|
||||||
|
|
||||||
|
### 5.2 Sauberes Beenden & Checkpointing
|
||||||
|
Beim regulären Beenden (`sanctum unmount`, Ctrl+C, Inaktivitäts-Timeout oder Windows-Sitzungssperre) führt Sanctum automatisch:
|
||||||
|
1. Einen vollständigen WAL-Checkpoint durch (`PRAGMA wal_checkpoint(TRUNCATE)`), der alle ausstehenden Transaktionen in die `.sanctum`-Hauptdatei überführt und die WAL-Datei auf 0 Bytes kürzt.
|
||||||
|
2. Das Schließen der Datenbankverbindung durch, woraufhin SQLite die `-wal`- und `-shm`-Dateien vom Dateisystem entfernt.
|
||||||
|
3. Ein explizites Bereinigungs-Fallback aus (`mount.rs`), das verbleibende Begleitdateien sicher vom Host-Dateisystem löscht.
|
||||||
|
|
||||||
|
### 5.3 Abrupter Systemabsturz & OpSec-Überlegungen
|
||||||
|
Wird der Rechner abrupt stromlos gemacht (`Hard Reset`), stürzt das Betriebssystem ab oder wird der Prozess via `SIGKILL` / Task-Manager beendet, verbleiben `<container>.sanctum-wal` und `-shm` möglicherweise auf dem Datenträger.
|
||||||
|
- **Datenintegrität**: Beim nächsten Öffnen des Containers führt SQLite automatisch ein WAL-Recovery durch, wodurch keine Daten verloren gehen und die Metadaten-Authentifizierung (K-01) intakt bleibt.
|
||||||
|
- **Forensische Sichtbarkeit**: Ein Angreifer kann aus der Existenz und Dateigröße der `-wal`-Datei ablesen, dass vor dem Absturz Schreiboperationen stattgefunden haben und wie viele Bytes modifiziert wurden.
|
||||||
|
- **Flash-Wear-Leveling**: Auf SSDs/NVMe-Speichern können gelöschte Dateisystem-Sektoren bis zur TRIM-Bereinigung physisch im Flash-Speicher existieren. Für maximale OpSec empfiehlt Sanctum die Ablage von Containern auf vollverschlüsselten Host-Laufwerken (BitLocker / LUKS) oder RAM-Disks.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"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",
|
||||||
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"architecture": {
|
"architecture": {
|
||||||
"64bit": {
|
"64bit": {
|
||||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
|
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
|
||||||
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
|
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
|
||||||
"bin": "sanctum.exe"
|
"bin": "sanctum.exe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||||
PackageIdentifier: HaraldPansi.Sanctum
|
PackageIdentifier: HaraldPansi.Sanctum
|
||||||
PackageVersion: 0.6.0
|
PackageVersion: 0.8.0
|
||||||
PackageName: Sanctum
|
PackageName: Sanctum
|
||||||
Publisher: Harald Pansi
|
Publisher: Harald Pansi
|
||||||
PublisherUrl: https://gitea.pansi.eu/harald
|
PublisherUrl: https://gitea.pansi.eu/harald
|
||||||
@@ -18,7 +18,7 @@ Tags:
|
|||||||
- webdav
|
- webdav
|
||||||
- container
|
- container
|
||||||
- plausible-deniability
|
- plausible-deniability
|
||||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
|
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.0
|
||||||
Installers:
|
Installers:
|
||||||
- Architecture: x64
|
- Architecture: x64
|
||||||
InstallerType: zip
|
InstallerType: zip
|
||||||
@@ -26,7 +26,7 @@ Installers:
|
|||||||
NestedInstallerFiles:
|
NestedInstallerFiles:
|
||||||
- RelativeFilePath: sanctum.exe
|
- RelativeFilePath: sanctum.exe
|
||||||
PortableCommandAlias: sanctum
|
PortableCommandAlias: sanctum
|
||||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
|
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
|
||||||
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
|
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
|
||||||
ManifestType: singleton
|
ManifestType: singleton
|
||||||
ManifestVersion: 1.6.0
|
ManifestVersion: 1.6.0
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
|||||||
if (Test-Path $KeyFile) {
|
if (Test-Path $KeyFile) {
|
||||||
if (Test-Path $MinisignExe) {
|
if (Test-Path $MinisignExe) {
|
||||||
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
||||||
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
|
||||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
|||||||
if (Test-Path $KeyFile) {
|
if (Test-Path $KeyFile) {
|
||||||
if (Test-Path $MinisignExe) {
|
if (Test-Path $MinisignExe) {
|
||||||
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
||||||
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
|
||||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+236
-8
@@ -23,7 +23,7 @@ use zeroize::{Zeroize, Zeroizing};
|
|||||||
|
|
||||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||||
use crate::storage::Database;
|
use crate::storage::Database;
|
||||||
use crate::vfs::{is_leak_file, SanctumDirEntry, SanctumMetaData};
|
use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData};
|
||||||
|
|
||||||
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
|
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
|
||||||
pub const CARRIER_VERSION: u32 = 1;
|
pub const CARRIER_VERSION: u32 = 1;
|
||||||
@@ -115,6 +115,7 @@ pub fn read_carrier_block(
|
|||||||
&chunk_rec.nonce,
|
&chunk_rec.nonce,
|
||||||
&chunk_rec.tag,
|
&chunk_rec.tag,
|
||||||
format_version,
|
format_version,
|
||||||
|
chunk_rec.generation,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Das outer_decrypted enthält: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding
|
// Das outer_decrypted enthält: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding
|
||||||
@@ -149,6 +150,7 @@ pub fn read_carrier_block(
|
|||||||
&inner_nonce,
|
&inner_nonce,
|
||||||
&inner_tag,
|
&inner_tag,
|
||||||
format_version,
|
format_version,
|
||||||
|
chunk_rec.generation,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(inner_plaintext)
|
Ok(inner_plaintext)
|
||||||
@@ -167,6 +169,8 @@ pub fn write_carrier_block(
|
|||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let gen = 0u64;
|
||||||
|
|
||||||
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
|
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
|
||||||
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
|
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
|
||||||
dek_inner,
|
dek_inner,
|
||||||
@@ -174,6 +178,7 @@ pub fn write_carrier_block(
|
|||||||
block_idx,
|
block_idx,
|
||||||
plaintext,
|
plaintext,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let inner_ct_len = inner_ct.len() as u32;
|
let inner_ct_len = inner_ct.len() as u32;
|
||||||
@@ -208,12 +213,14 @@ pub fn write_carrier_block(
|
|||||||
block_idx,
|
block_idx,
|
||||||
&outer_plaintext,
|
&outer_plaintext,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
|
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
|
||||||
db.write_chunk(
|
db.write_chunk(
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
|
gen,
|
||||||
&outer_nonce,
|
&outer_nonce,
|
||||||
&outer_tag,
|
&outer_tag,
|
||||||
&outer_ct,
|
&outer_ct,
|
||||||
@@ -234,17 +241,20 @@ pub fn shred_carrier_block(
|
|||||||
let mut noise = vec![0u8; CHUNK_SIZE];
|
let mut noise = vec![0u8; CHUNK_SIZE];
|
||||||
OsRng.fill_bytes(&mut noise);
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
|
||||||
|
let gen = 0u64;
|
||||||
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
||||||
dek_outer,
|
dek_outer,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
&noise,
|
&noise,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
db.write_chunk(
|
db.write_chunk(
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
|
gen,
|
||||||
&outer_nonce,
|
&outer_nonce,
|
||||||
&outer_tag,
|
&outer_tag,
|
||||||
&outer_ct,
|
&outer_ct,
|
||||||
@@ -261,11 +271,16 @@ pub struct CarrierFsInner {
|
|||||||
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
||||||
pub format_version: u32,
|
pub format_version: u32,
|
||||||
pub anti_leak: bool,
|
pub anti_leak: bool,
|
||||||
|
pub custom_leak_rules: Arc<Vec<String>>,
|
||||||
|
pub leak_counter: Arc<AtomicU64>,
|
||||||
pub manifest: CarrierManifest,
|
pub manifest: CarrierManifest,
|
||||||
pub last_activity: Arc<AtomicU64>,
|
pub last_activity: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierFsInner {
|
impl CarrierFsInner {
|
||||||
|
pub fn is_leak(&self, name: &str) -> bool {
|
||||||
|
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
|
||||||
|
}
|
||||||
pub fn save_manifest(&mut self) -> Result<()> {
|
pub fn save_manifest(&mut self) -> Result<()> {
|
||||||
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
|
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
|
||||||
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
|
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
|
||||||
@@ -359,14 +374,15 @@ pub struct CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierFs {
|
impl CarrierFs {
|
||||||
/// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei.
|
/// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04).
|
||||||
pub fn load(
|
pub fn load_with_leak_rules(
|
||||||
db: Database,
|
db: Database,
|
||||||
carrier_node_id: i64,
|
carrier_node_id: i64,
|
||||||
dek_outer: Arc<Zeroizing<[u8; 32]>>,
|
dek_outer: Arc<Zeroizing<[u8; 32]>>,
|
||||||
dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
|
custom_leak_rules: Vec<String>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let manifest_bytes = read_carrier_block(
|
let manifest_bytes = read_carrier_block(
|
||||||
&db,
|
&db,
|
||||||
@@ -398,6 +414,8 @@ impl CarrierFs {
|
|||||||
dek_inner,
|
dek_inner,
|
||||||
format_version,
|
format_version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules: Arc::new(custom_leak_rules),
|
||||||
|
leak_counter: Arc::new(AtomicU64::new(0)),
|
||||||
manifest,
|
manifest,
|
||||||
last_activity: Arc::new(AtomicU64::new(now)),
|
last_activity: Arc::new(AtomicU64::new(now)),
|
||||||
};
|
};
|
||||||
@@ -407,8 +425,33 @@ impl CarrierFs {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei.
|
||||||
|
pub fn load(
|
||||||
|
db: Database,
|
||||||
|
carrier_node_id: i64,
|
||||||
|
dek_outer: Arc<Zeroizing<[u8; 32]>>,
|
||||||
|
dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
||||||
|
format_version: u32,
|
||||||
|
anti_leak: bool,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::load_with_leak_rules(
|
||||||
|
db,
|
||||||
|
carrier_node_id,
|
||||||
|
dek_outer,
|
||||||
|
dek_inner,
|
||||||
|
format_version,
|
||||||
|
anti_leak,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leak_counter(&self) -> Arc<AtomicU64> {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.leak_counter.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn last_activity(&self) -> Arc<AtomicU64> {
|
pub fn last_activity(&self) -> Arc<AtomicU64> {
|
||||||
let inner = self.inner.lock().unwrap();
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
inner.last_activity.clone()
|
inner.last_activity.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,13 +482,14 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
let (parent_path, file_name) = inner.split_parent_and_name(&path_str);
|
let (parent_path, file_name) = inner.split_parent_and_name(&path_str);
|
||||||
|
|
||||||
if inner.anti_leak && is_leak_file(file_name) {
|
if inner.is_leak(file_name) {
|
||||||
if options.create
|
if options.create
|
||||||
|| options.create_new
|
|| options.create_new
|
||||||
|| options.write
|
|| options.write
|
||||||
|| options.append
|
|| options.append
|
||||||
|| options.truncate
|
|| options.truncate
|
||||||
{
|
{
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,7 +588,14 @@ impl DavFileSystem for CarrierFs {
|
|||||||
.inodes
|
.inodes
|
||||||
.values()
|
.values()
|
||||||
.filter(|child| child.parent_id == Some(node.id))
|
.filter(|child| child.parent_id == Some(node.id))
|
||||||
.filter(|child| !inner.anti_leak || !is_leak_file(&child.name))
|
.filter(|child| {
|
||||||
|
if inner.is_leak(&child.name) {
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(|child| {
|
.map(|child| {
|
||||||
Ok(Box::new(SanctumDirEntry {
|
Ok(Box::new(SanctumDirEntry {
|
||||||
name: child.name.clone(),
|
name: child.name.clone(),
|
||||||
@@ -591,7 +642,8 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
let (parent_path, dir_name) = inner.split_parent_and_name(&path_str);
|
let (parent_path, dir_name) = inner.split_parent_and_name(&path_str);
|
||||||
|
|
||||||
if inner.anti_leak && is_leak_file(dir_name) {
|
if inner.is_leak(dir_name) {
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +754,8 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
|
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
|
||||||
|
|
||||||
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
|
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
|
||||||
if inner.anti_leak && is_leak_file(to_name) {
|
if inner.is_leak(to_name) {
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,6 +800,152 @@ impl DavFileSystem for CarrierFs {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.touch();
|
||||||
|
let from_str = Self::path_to_str(from);
|
||||||
|
let to_str = Self::path_to_str(to);
|
||||||
|
|
||||||
|
if from_str == to_str {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
|
||||||
|
|
||||||
|
if node.is_dir {
|
||||||
|
return Err(FsError::NotImplemented);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
|
||||||
|
if inner.is_leak(to_name) {
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_parent = inner
|
||||||
|
.resolve_path(to_parent_path)
|
||||||
|
.ok_or(FsError::NotFound)?;
|
||||||
|
if !to_parent.is_dir {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls Zieldatei existiert und ein Verzeichnis ist: Fehler
|
||||||
|
if let Some(dest) = inner.resolve_path(&to_str) {
|
||||||
|
if dest.is_dir {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
for b in dest.blocks {
|
||||||
|
let _ = shred_carrier_block(
|
||||||
|
&inner.db,
|
||||||
|
inner.carrier_node_id,
|
||||||
|
b,
|
||||||
|
&inner.dek_outer,
|
||||||
|
inner.format_version,
|
||||||
|
);
|
||||||
|
inner.manifest.free_blocks.push(b);
|
||||||
|
}
|
||||||
|
inner.manifest.inodes.remove(&dest.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.manifest.free_blocks.len() < node.blocks.len() {
|
||||||
|
return Err(FsError::InsufficientStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dest_blocks = Vec::with_capacity(node.blocks.len());
|
||||||
|
for &src_block_idx in &node.blocks {
|
||||||
|
let dest_block_idx = match inner.allocate_block() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
for b in dest_blocks {
|
||||||
|
let _ = inner.free_block(b);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = match read_carrier_block(
|
||||||
|
&inner.db,
|
||||||
|
inner.carrier_node_id,
|
||||||
|
src_block_idx,
|
||||||
|
&inner.dek_outer,
|
||||||
|
&inner.dek_inner,
|
||||||
|
inner.format_version,
|
||||||
|
) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Fehler beim Lesen des Quell-Blocks während copy: {e}");
|
||||||
|
let _ = inner.free_block(dest_block_idx);
|
||||||
|
for b in dest_blocks {
|
||||||
|
let _ = inner.free_block(b);
|
||||||
|
}
|
||||||
|
return Err(FsError::GeneralFailure);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = write_carrier_block(
|
||||||
|
&inner.db,
|
||||||
|
inner.carrier_node_id,
|
||||||
|
dest_block_idx,
|
||||||
|
&inner.dek_outer,
|
||||||
|
&inner.dek_inner,
|
||||||
|
&data,
|
||||||
|
inner.format_version,
|
||||||
|
) {
|
||||||
|
error!("Fehler beim Schreiben des Ziel-Blocks während copy: {e}");
|
||||||
|
let _ = inner.free_block(dest_block_idx);
|
||||||
|
for b in dest_blocks {
|
||||||
|
let _ = inner.free_block(b);
|
||||||
|
}
|
||||||
|
return Err(FsError::GeneralFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
dest_blocks.push(dest_block_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let new_id = inner.manifest.next_inode_id;
|
||||||
|
inner.manifest.next_inode_id += 1;
|
||||||
|
|
||||||
|
let new_inode = CarrierInode {
|
||||||
|
id: new_id,
|
||||||
|
parent_id: Some(to_parent.id),
|
||||||
|
name: to_name.to_string(),
|
||||||
|
is_dir: false,
|
||||||
|
size: node.size,
|
||||||
|
created_at: now,
|
||||||
|
modified_at: now,
|
||||||
|
blocks: dest_blocks,
|
||||||
|
};
|
||||||
|
|
||||||
|
inner.manifest.inodes.insert(new_id, new_inode);
|
||||||
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_quota(&self) -> FsFuture<'_, (u64, Option<u64>)> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.touch();
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let total_capacity =
|
||||||
|
inner.manifest.total_blocks as u64 * crate::crypto::CHUNK_SIZE as u64;
|
||||||
|
let used_bytes = inner
|
||||||
|
.manifest
|
||||||
|
.inodes
|
||||||
|
.values()
|
||||||
|
.filter(|i| !i.is_dir)
|
||||||
|
.map(|i| i.size)
|
||||||
|
.sum::<u64>();
|
||||||
|
Ok((used_bytes, Some(total_capacity)))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Datei-Handle für Dateien innerhalb des Carrier-Dateisystems mit Streaming und Chunk-Pufferung.
|
/// Datei-Handle für Dateien innerhalb des Carrier-Dateisystems mit Streaming und Chunk-Pufferung.
|
||||||
@@ -919,6 +1118,35 @@ impl DavFile for CarrierFile {
|
|||||||
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
||||||
self.touch();
|
self.touch();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
// V-06: Sparse Writes — Lücke zwischen bisherigem Dateiende und Cursor mit Nullen füllen
|
||||||
|
if self.cursor > self.file_size {
|
||||||
|
let target = self.cursor;
|
||||||
|
while self.file_size < target {
|
||||||
|
let block_idx = (self.file_size / CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize;
|
||||||
|
let offset_in_block =
|
||||||
|
(self.file_size % CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize;
|
||||||
|
let space_in_block = CARRIER_BLOCK_PAYLOAD_SIZE - offset_in_block;
|
||||||
|
let to_pad = ((target - self.file_size) as usize).min(space_in_block);
|
||||||
|
|
||||||
|
let block_data = self.ensure_block_loaded(block_idx)?;
|
||||||
|
if block_data.len() < offset_in_block + to_pad {
|
||||||
|
block_data.resize(offset_in_block + to_pad, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut is_full = false;
|
||||||
|
if let Some((_, ref d, ref mut dirty)) = self.cached_block {
|
||||||
|
*dirty = true;
|
||||||
|
is_full = d.len() >= CARRIER_BLOCK_PAYLOAD_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file_size += to_pad as u64;
|
||||||
|
|
||||||
|
if is_full {
|
||||||
|
self.flush_cached_block()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut src = &buf[..];
|
let mut src = &buf[..];
|
||||||
|
|
||||||
while !src.is_empty() {
|
while !src.is_empty() {
|
||||||
|
|||||||
+321
-19
@@ -7,20 +7,23 @@ use argon2::{Algorithm, Argon2, Params, Version};
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
||||||
pub const FORMAT_VERSION_V1: u32 = 1;
|
pub const FORMAT_VERSION_V1: u32 = 1;
|
||||||
pub const FORMAT_VERSION_V2: u32 = 2;
|
pub const FORMAT_VERSION_V2: u32 = 2;
|
||||||
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2;
|
pub const FORMAT_VERSION_V3: u32 = 3;
|
||||||
|
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V3;
|
||||||
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
|
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
|
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
|
||||||
pub const COMPRESSION_NONE: u8 = 0x00;
|
pub const COMPRESSION_NONE: u8 = 0x00;
|
||||||
pub const COMPRESSION_LZ4: u8 = 0x01;
|
pub const COMPRESSION_LZ4: u8 = 0x01;
|
||||||
|
|
||||||
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
|
pub const DEFAULT_MEMORY_COST_KIB: u32 = 256 * 1024; // 256 MB (262_144 KiB)
|
||||||
pub const DEFAULT_TIME_COST: u32 = 3;
|
pub const DEFAULT_TIME_COST: u32 = 4;
|
||||||
pub const DEFAULT_PARALLELISM: u32 = 4;
|
pub const DEFAULT_PARALLELISM: u32 = 4;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -101,7 +104,20 @@ pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zeroizing<[u8; 32]>> {
|
/// RAII-Guard für kurzzeitige Stack-Puffer, um Lock-Leaks bei Fehlern oder Rückgabe zu verhindern (V-03).
|
||||||
|
struct ScopedMemoryLock(*const u8, usize);
|
||||||
|
|
||||||
|
impl Drop for ScopedMemoryLock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
crate::windows::unlock_memory(self.0, self.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_kek(
|
||||||
|
password: &str,
|
||||||
|
salt: &[u8; 16],
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
validate_kdf_params(params)?;
|
validate_kdf_params(params)?;
|
||||||
|
|
||||||
let argon2_params = Params::new(
|
let argon2_params = Params::new(
|
||||||
@@ -115,12 +131,11 @@ pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zer
|
|||||||
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);
|
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
|
||||||
|
let _lock_guard = ScopedMemoryLock(kek.as_ptr(), 32);
|
||||||
|
|
||||||
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
|
argon2
|
||||||
if let Err(e) = res {
|
.hash_password_into(password.as_bytes(), salt, &mut *kek)
|
||||||
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
|
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
|
||||||
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(kek)
|
Ok(kek)
|
||||||
}
|
}
|
||||||
@@ -129,6 +144,7 @@ pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zer
|
|||||||
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);
|
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
||||||
|
let _lock_guard = ScopedMemoryLock(dek.as_ptr(), 32);
|
||||||
OsRng.fill_bytes(&mut *dek);
|
OsRng.fill_bytes(&mut *dek);
|
||||||
dek
|
dek
|
||||||
}
|
}
|
||||||
@@ -554,6 +570,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
|||||||
Ok(dek)
|
Ok(dek)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Berechnet HMAC-SHA256 gemäß RFC 2104.
|
||||||
|
pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
|
||||||
|
let mut key_block = [0u8; 64];
|
||||||
|
if key.len() > 64 {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(key);
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
key_block[..32].copy_from_slice(&hash);
|
||||||
|
} else {
|
||||||
|
key_block[..key.len()].copy_from_slice(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut k_ipad = [0u8; 64];
|
||||||
|
let mut k_opad = [0u8; 64];
|
||||||
|
for i in 0..64 {
|
||||||
|
k_ipad[i] = key_block[i] ^ 0x36;
|
||||||
|
k_opad[i] = key_block[i] ^ 0x5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner_hasher = Sha256::new();
|
||||||
|
inner_hasher.update(k_ipad);
|
||||||
|
inner_hasher.update(data);
|
||||||
|
let inner_hash = inner_hasher.finalize();
|
||||||
|
|
||||||
|
let mut outer_hasher = Sha256::new();
|
||||||
|
outer_hasher.update(k_opad);
|
||||||
|
outer_hasher.update(inner_hash);
|
||||||
|
let out = outer_hasher.finalize();
|
||||||
|
|
||||||
|
let mut result = [0u8; 32];
|
||||||
|
result.copy_from_slice(&out);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leitet einen 32-Byte Schlüssel mittels HKDF-SHA256 (RFC 5869) aus IKM und Info-String ab.
|
||||||
|
pub fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let prk = hmac_sha256(&salt, ikm);
|
||||||
|
|
||||||
|
let mut expand_input = Vec::with_capacity(info.len() + 1);
|
||||||
|
expand_input.extend_from_slice(info);
|
||||||
|
expand_input.push(0x01);
|
||||||
|
|
||||||
|
hmac_sha256(&prk, &expand_input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leitet den dedizierten Metadaten-MAC-Schlüssel aus dem DEK ab (Format V3 / K-01).
|
||||||
|
pub fn derive_metadata_mac_key(dek: &[u8; 32]) -> [u8; 32] {
|
||||||
|
hkdf_sha256(dek, b"SANCTUM_META_MAC_V3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Berechnet den Metadaten-MAC über kanonisch serialisierte Knoten und Generation (Format V3 / K-01).
|
||||||
|
pub fn compute_metadata_mac(
|
||||||
|
mac_key: &[u8; 32],
|
||||||
|
metadata_gen: u64,
|
||||||
|
canonical_nodes: &[u8],
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let mut data = Vec::with_capacity(24 + canonical_nodes.len());
|
||||||
|
data.extend_from_slice(b"SANCTUM_META_V3\0");
|
||||||
|
data.extend_from_slice(&metadata_gen.to_le_bytes());
|
||||||
|
data.extend_from_slice(canonical_nodes);
|
||||||
|
hmac_sha256(mac_key, &data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC gültig ist (Format V3 / K-01).
|
||||||
|
pub fn verify_metadata_mac(
|
||||||
|
mac_key: &[u8; 32],
|
||||||
|
metadata_gen: u64,
|
||||||
|
canonical_nodes: &[u8],
|
||||||
|
expected_mac: &[u8; 32],
|
||||||
|
) -> bool {
|
||||||
|
let computed = compute_metadata_mac(mac_key, metadata_gen, canonical_nodes);
|
||||||
|
computed.ct_eq(expected_mac).into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
|
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
|
||||||
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -564,9 +655,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
|
|||||||
aad
|
aad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erzeugt die 24-Byte Associated Data (AAD) für einen Chunk in Format V3 (K-02 Chunk-Replay-Schutz):
|
||||||
|
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian) || generation (8 Bytes Little-Endian).
|
||||||
|
#[inline]
|
||||||
|
pub fn build_chunk_aad_v3(node_id: i64, chunk_index: u32, generation: u64) -> [u8; 24] {
|
||||||
|
let mut aad = [0u8; 24];
|
||||||
|
aad[..8].copy_from_slice(&node_id.to_le_bytes());
|
||||||
|
aad[8..16].copy_from_slice(&(chunk_index as u64).to_le_bytes());
|
||||||
|
aad[16..24].copy_from_slice(&generation.to_le_bytes());
|
||||||
|
aad
|
||||||
|
}
|
||||||
|
|
||||||
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
|
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
|
||||||
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
|
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
|
||||||
/// sofern dadurch eine Größenreduktion erzielt wird.
|
/// sofern dadurch eine Größenreduktion erzielt wird.
|
||||||
|
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers verwendet (K-02).
|
||||||
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
|
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
|
||||||
pub fn encrypt_chunk(
|
pub fn encrypt_chunk(
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
@@ -574,6 +677,7 @@ pub fn encrypt_chunk(
|
|||||||
chunk_index: u32,
|
chunk_index: u32,
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
|
generation: u64,
|
||||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||||
@@ -582,7 +686,15 @@ pub fn encrypt_chunk(
|
|||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
let aad = build_chunk_aad(node_id, chunk_index);
|
let aad_16;
|
||||||
|
let aad_24;
|
||||||
|
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
|
||||||
|
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
|
||||||
|
&aad_24
|
||||||
|
} else {
|
||||||
|
aad_16 = build_chunk_aad(node_id, chunk_index);
|
||||||
|
&aad_16
|
||||||
|
};
|
||||||
|
|
||||||
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
|
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
|
||||||
if plaintext.is_empty() {
|
if plaintext.is_empty() {
|
||||||
@@ -607,7 +719,7 @@ pub fn encrypt_chunk(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tag = cipher
|
let tag = cipher
|
||||||
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
|
.encrypt_in_place_detached(nonce, aad, &mut buffer)
|
||||||
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
|
||||||
|
|
||||||
let mut tag_bytes = [0u8; 16];
|
let mut tag_bytes = [0u8; 16];
|
||||||
@@ -618,6 +730,7 @@ pub fn encrypt_chunk(
|
|||||||
|
|
||||||
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
|
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
|
||||||
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
|
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
|
||||||
|
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers geprüft (K-02).
|
||||||
pub fn decrypt_chunk(
|
pub fn decrypt_chunk(
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
node_id: i64,
|
node_id: i64,
|
||||||
@@ -626,17 +739,27 @@ pub fn decrypt_chunk(
|
|||||||
nonce_bytes: &[u8; 12],
|
nonce_bytes: &[u8; 12],
|
||||||
tag_bytes: &[u8; 16],
|
tag_bytes: &[u8; 16],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
|
generation: u64,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||||
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
let tag = Tag::from_slice(tag_bytes);
|
let tag = Tag::from_slice(tag_bytes);
|
||||||
let aad = build_chunk_aad(node_id, chunk_index);
|
|
||||||
|
let aad_16;
|
||||||
|
let aad_24;
|
||||||
|
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
|
||||||
|
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
|
||||||
|
&aad_24
|
||||||
|
} else {
|
||||||
|
aad_16 = build_chunk_aad(node_id, chunk_index);
|
||||||
|
&aad_16
|
||||||
|
};
|
||||||
|
|
||||||
let mut buffer = ciphertext.to_vec();
|
let mut buffer = ciphertext.to_vec();
|
||||||
cipher
|
cipher
|
||||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
.decrypt_in_place_detached(nonce, aad, &mut buffer, tag)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
|
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
|
||||||
@@ -709,6 +832,28 @@ mod tests {
|
|||||||
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
|
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m01_default_kdf_params_256mib_4_iterations() {
|
||||||
|
let defaults = KdfParams::default();
|
||||||
|
assert_eq!(
|
||||||
|
defaults.memory_cost,
|
||||||
|
256 * 1024,
|
||||||
|
"Default memory cost must be 256 MiB (262,144 KiB)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
defaults.time_cost, 4,
|
||||||
|
"Default time cost must be 4 iterations"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
defaults.parallelism, 4,
|
||||||
|
"Default parallelism must be 4 threads"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_kdf_params(&defaults).is_ok(),
|
||||||
|
"Default KDF parameters must pass validation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_chunk_encryption_and_swap_protection() {
|
fn test_chunk_encryption_and_swap_protection() {
|
||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
@@ -717,7 +862,7 @@ mod tests {
|
|||||||
let chunk_index = 0u32;
|
let chunk_index = 0u32;
|
||||||
|
|
||||||
let (ciphertext, nonce, tag) =
|
let (ciphertext, nonce, tag) =
|
||||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap();
|
||||||
|
|
||||||
// Reguläre Entschlüsselung (v2)
|
// Reguläre Entschlüsselung (v2)
|
||||||
let decrypted = decrypt_chunk(
|
let decrypted = decrypt_chunk(
|
||||||
@@ -728,6 +873,7 @@ mod tests {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V2,
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
@@ -741,6 +887,7 @@ mod tests {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V2,
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert!(swap_node_err.is_err());
|
assert!(swap_node_err.is_err());
|
||||||
|
|
||||||
@@ -753,6 +900,7 @@ mod tests {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V2,
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert!(swap_idx_err.is_err());
|
assert!(swap_idx_err.is_err());
|
||||||
|
|
||||||
@@ -766,11 +914,124 @@ mod tests {
|
|||||||
&tampered_ct,
|
&tampered_ct,
|
||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V2
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_k02_chunk_replay_protection_with_generation_aad() {
|
||||||
|
let dek = generate_dek();
|
||||||
|
let plaintext_v1 = b"Original Chunk Data at Generation 1";
|
||||||
|
let plaintext_v2 = b"Overwritten Chunk Data at Generation 2";
|
||||||
|
let node_id = 42i64;
|
||||||
|
let chunk_index = 0u32;
|
||||||
|
|
||||||
|
// 1. Chunk mit Generation 1 verschlüsseln
|
||||||
|
let (ct1, nonce1, tag1) = encrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
plaintext_v1,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verifiziere reguläre Entschlüsselung mit Generation 1
|
||||||
|
let dec1 = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ct1,
|
||||||
|
&nonce1,
|
||||||
|
&tag1,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dec1, plaintext_v1);
|
||||||
|
|
||||||
|
// 2. Replay-Schutz: Entschlüsselung mit falscher Generation (z. B. 2) MUSS scheitern!
|
||||||
|
let replay_err = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ct1,
|
||||||
|
&nonce1,
|
||||||
|
&tag1,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
replay_err.is_err(),
|
||||||
|
"Ciphertext von Gen 1 darf unter Gen 2 AAD nicht entschlüsselt werden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Chunk überschreiben mit Generation 2
|
||||||
|
let (ct2, nonce2, tag2) = encrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
plaintext_v2,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dec2 = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ct2,
|
||||||
|
&nonce2,
|
||||||
|
&tag2,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dec2, plaintext_v2);
|
||||||
|
|
||||||
|
// 4. Replay-Angriff: Angreifer spielt ct1 (Gen 1) ein, während System Gen 2 erwartet
|
||||||
|
let attack_res = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ct1,
|
||||||
|
&nonce1,
|
||||||
|
&tag1,
|
||||||
|
FORMAT_VERSION_V3,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
attack_res.is_err(),
|
||||||
|
"Replay von altem Ciphertext muss abgewehrt werden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Abwärtskompatibilität: In V2 wird generation ignoriert
|
||||||
|
let (ct_v2, nonce_v2, tag_v2) = encrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
plaintext_v1,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dec_v2 = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ct_v2,
|
||||||
|
&nonce_v2,
|
||||||
|
&tag_v2,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
999, // beliebig
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dec_v2, plaintext_v1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lz4_chunk_compression_efficiency() {
|
fn test_lz4_chunk_compression_efficiency() {
|
||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
@@ -781,7 +1042,7 @@ mod tests {
|
|||||||
let chunk_index = 0u32;
|
let chunk_index = 0u32;
|
||||||
|
|
||||||
let (ciphertext, nonce, tag) =
|
let (ciphertext, nonce, tag) =
|
||||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap();
|
||||||
|
|
||||||
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
|
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
|
||||||
assert!(
|
assert!(
|
||||||
@@ -799,6 +1060,7 @@ mod tests {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V2,
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
@@ -811,11 +1073,12 @@ mod tests {
|
|||||||
let mut random_bytes = vec![0u8; 1000];
|
let mut random_bytes = vec![0u8; 1000];
|
||||||
OsRng.fill_bytes(&mut random_bytes);
|
OsRng.fill_bytes(&mut random_bytes);
|
||||||
|
|
||||||
let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
let (ct, nonce, tag) =
|
||||||
|
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2, 0).unwrap();
|
||||||
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
||||||
assert_eq!(ct.len(), random_bytes.len() + 1);
|
assert_eq!(ct.len(), random_bytes.len() + 1);
|
||||||
|
|
||||||
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2, 0).unwrap();
|
||||||
assert_eq!(decrypted, random_bytes);
|
assert_eq!(decrypted, random_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,7 +1091,7 @@ mod tests {
|
|||||||
|
|
||||||
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
||||||
let (ciphertext, nonce, tag) =
|
let (ciphertext, nonce, tag) =
|
||||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
|
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1, 0).unwrap();
|
||||||
assert_eq!(ciphertext.len(), plaintext.len());
|
assert_eq!(ciphertext.len(), plaintext.len());
|
||||||
|
|
||||||
let decrypted = decrypt_chunk(
|
let decrypted = decrypt_chunk(
|
||||||
@@ -839,6 +1102,7 @@ mod tests {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
FORMAT_VERSION_V1,
|
FORMAT_VERSION_V1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
@@ -1085,6 +1349,7 @@ mod tests {
|
|||||||
&nonce_bytes,
|
&nonce_bytes,
|
||||||
&tag_bytes,
|
&tag_bytes,
|
||||||
FORMAT_VERSION_V2,
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
res.is_err(),
|
res.is_err(),
|
||||||
@@ -1097,4 +1362,41 @@ mod tests {
|
|||||||
err_msg
|
err_msg
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hmac_sha256_rfc4231_test_case_1() {
|
||||||
|
// RFC 4231 Test Case 1: Key = 20x 0x0b, Data = "Hi There"
|
||||||
|
let key = [0x0b; 20];
|
||||||
|
let data = b"Hi There";
|
||||||
|
let mac = hmac_sha256(&key, data);
|
||||||
|
let expected_hex = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
|
||||||
|
assert_eq!(hex::encode(mac), expected_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_mac_verification() {
|
||||||
|
let dek = generate_dek();
|
||||||
|
let mac_key = derive_metadata_mac_key(&dek);
|
||||||
|
let canonical_nodes = b"node_canonical_bytes_mock_12345";
|
||||||
|
let gen = 0u64;
|
||||||
|
|
||||||
|
let mac = compute_metadata_mac(&mac_key, gen, canonical_nodes);
|
||||||
|
assert!(verify_metadata_mac(&mac_key, gen, canonical_nodes, &mac));
|
||||||
|
|
||||||
|
// Manipulierte Generation -> ungültig
|
||||||
|
assert!(!verify_metadata_mac(
|
||||||
|
&mac_key,
|
||||||
|
gen + 1,
|
||||||
|
canonical_nodes,
|
||||||
|
&mac
|
||||||
|
));
|
||||||
|
|
||||||
|
// Manipulierte Knoten-Bytes -> ungültig
|
||||||
|
assert!(!verify_metadata_mac(&mac_key, gen, b"tampered_nodes", &mac));
|
||||||
|
|
||||||
|
// Falscher Schlüssel -> ungültig
|
||||||
|
let wrong_dek = generate_dek();
|
||||||
|
let wrong_key = derive_metadata_mac_key(&wrong_dek);
|
||||||
|
assert!(!verify_metadata_mac(&wrong_key, gen, canonical_nodes, &mac));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-1
@@ -8,7 +8,7 @@ use zeroize::Zeroizing;
|
|||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
|
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
|
||||||
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
|
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
|
||||||
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
wrap_slot1_payload, KdfParams, FORMAT_VERSION, FORMAT_VERSION_V3,
|
||||||
};
|
};
|
||||||
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::{
|
||||||
@@ -110,6 +110,10 @@ enum Commands {
|
|||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
no_anti_leak: bool,
|
no_anti_leak: bool,
|
||||||
|
|
||||||
|
/// Pfad zu einer Textdatei mit zusätzlichen Anti-Leak Filterregeln (V-04)
|
||||||
|
#[arg(long, value_name = "FILE")]
|
||||||
|
anti_leak_list: Option<PathBuf>,
|
||||||
|
|
||||||
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
|
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
|
||||||
#[arg(short, long, default_value_t = false)]
|
#[arg(short, long, default_value_t = false)]
|
||||||
stealth: bool,
|
stealth: bool,
|
||||||
@@ -134,6 +138,13 @@ enum Commands {
|
|||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Erneuert den internen Verschlüsselungsschlüssel (DEK), verschlüsselt alle Chunks um und generiert ein neues Notfallblatt (K-03)
|
||||||
|
Rekey {
|
||||||
|
/// Pfad zur .sanctum Containerdatei
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
|
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
|
||||||
Backup {
|
Backup {
|
||||||
/// Pfad zur Quelldatei (.sanctum Containerdatei)
|
/// Pfad zur Quelldatei (.sanctum Containerdatei)
|
||||||
@@ -226,6 +237,10 @@ enum Commands {
|
|||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
delete: bool,
|
delete: bool,
|
||||||
|
|
||||||
|
/// Löscht auch ausgeschlossene Dateien im Ziel bei aktiver --delete Spiegelung
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
delete_excluded: bool,
|
||||||
|
|
||||||
/// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen
|
/// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen
|
||||||
#[arg(short = 'n', long, default_value_t = false)]
|
#[arg(short = 'n', long, default_value_t = false)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
@@ -242,6 +257,18 @@ enum Commands {
|
|||||||
#[arg(short, long, default_value_t = false)]
|
#[arg(short, long, default_value_t = false)]
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
|
||||||
|
/// Erzwingt die Synchronisation auch bei erkanntem Advisory Lock eines anderen Prozesses (S-06)
|
||||||
|
#[arg(short = 'f', long, default_value_t = false)]
|
||||||
|
force: bool,
|
||||||
|
|
||||||
|
/// Erstellt Sicherungskopien (.bak) überschriebener lokaler Dateien beim Pull (S-09)
|
||||||
|
#[arg(short = 'b', long, default_value_t = false)]
|
||||||
|
backup: bool,
|
||||||
|
|
||||||
|
/// Verhindert das Überschreiben neuerer lokaler Zieldateien beim Pull (S-09)
|
||||||
|
#[arg(short = 'u', long, default_value_t = false)]
|
||||||
|
update: bool,
|
||||||
|
|
||||||
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
||||||
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
@@ -276,6 +303,13 @@ enum Commands {
|
|||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
insecure_url: bool,
|
insecure_url: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Migriert einen bestehenden Container auf Format V3 (Metadaten-Authentifizierung & Replay-Schutz)
|
||||||
|
UpgradeFormat {
|
||||||
|
/// Pfad zur .sanctum Containerdatei
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_drive_letter(s: &str) -> Result<char> {
|
fn parse_drive_letter(s: &str) -> Result<char> {
|
||||||
@@ -619,6 +653,7 @@ fn handle_init(
|
|||||||
let db =
|
let db =
|
||||||
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||||
|
|
||||||
|
db.set_active_dek(dek.clone());
|
||||||
db.init_schema_with_carrier(
|
db.init_schema_with_carrier(
|
||||||
&salt,
|
&salt,
|
||||||
&kdf_params,
|
&kdf_params,
|
||||||
@@ -867,6 +902,86 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
println!(" • KDF: Argon2id mit frischem Salt");
|
println!(" • KDF: Argon2id mit frischem Salt");
|
||||||
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
|
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
|
||||||
println!();
|
println!();
|
||||||
|
println!(" [!] HINWEIS: Das 24-Wörter-Notfallblatt bleibt weiterhin gültig!");
|
||||||
|
println!(" Um den Container vollständig mit neuem Master-Schlüssel zu sichern,");
|
||||||
|
println!(" nutzen Sie `sanctum rekey`.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_rekey(container_path: &Path) -> Result<()> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Master-Schlüssel erneuern (Rekeying) │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Container: {}", container_path.display());
|
||||||
|
println!();
|
||||||
|
println!(" [!] WICHTIGER SICHERHEITSHINWEIS (K-03):");
|
||||||
|
println!(" Beim Rekeying wird der interne Daten-Verschlüsselungsschlüssel (DEK)");
|
||||||
|
println!(" neu generiert und alle Datenblöcke werden umverschlüsselt.");
|
||||||
|
println!(" Das bisherige 24-Wörter-Notfallblatt wird damit UNWIDERRUFLICH UNGÜLTIG.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let password = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Master-Passwort des Containers eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Master-Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
if password.trim().is_empty() {
|
||||||
|
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
ui::step(
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
"🔄",
|
||||||
|
"Entschlüssele Datenblöcke & verschlüssele mit neuem DEK...",
|
||||||
|
);
|
||||||
|
let new_phrase = sanctum::recovery::rekey_container(container_path, &password)
|
||||||
|
.context("Fehler beim Ausführen des Rekeyings")?;
|
||||||
|
ui::step(
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
"🔒",
|
||||||
|
"Aktualisiere Container-Header & Metadaten-Authentifizierung...",
|
||||||
|
);
|
||||||
|
ui::step(3, 3, "🔑", "Generiere neues 24-Wort BIP-39 Notfallblatt...");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ✔ Container erfolgreich rekeyed! │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!();
|
||||||
|
println!(" • Container: {}", container_path.display());
|
||||||
|
println!(" • Status: Alle Chunks wurden unter einem frischen DEK neu verschlüsselt.");
|
||||||
|
println!(" • Alt-Schlüssel:Bisheriges Notfallblatt ist ab sofort ungültig und entwertet.");
|
||||||
|
println!();
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ NEUES 24-WORT BIP-39 NOTFALLBLATT (NOTFALL-WIEDERHERSTELLUNG)│");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!();
|
||||||
|
let words: Vec<&str> = new_phrase.split_whitespace().collect();
|
||||||
|
for (i, chunk) in words.chunks(4).enumerate() {
|
||||||
|
let line = chunk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(j, w)| format!("{:2}. {:<12}", i * 4 + j + 1, w))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
println!(" {}", line);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(" [!] WICHTIG: Drucken Sie diese Wörter aus oder notieren Sie sie sicher.");
|
||||||
|
println!(" Das alte Notfallblatt kann nicht mehr zur Wiederherstellung verwendet werden.");
|
||||||
|
println!();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1210,16 +1325,62 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_upgrade_format(container_path: &Path) -> Result<()> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Format V3 Upgrade │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Container: {}", container_path.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let password = rpassword::prompt_password("Master-Passwort: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?;
|
||||||
|
|
||||||
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
let keys = match db.authenticate_password(&password)? {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
bail!("Authentifizierung fehlgeschlagen: Falsches Passwort");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if keys.version() >= FORMAT_VERSION_V3 {
|
||||||
|
println!(
|
||||||
|
" • Container ist bereits Format V{} (keine Migration erforderlich).",
|
||||||
|
keys.version()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
db.upgrade_to_v3(keys.dek())?;
|
||||||
|
db.checkpoint()?;
|
||||||
|
|
||||||
|
println!(" ✔ Container erfolgreich auf Format V3 aktualisiert!");
|
||||||
|
println!(" • Metadaten-Authentifizierung (HMAC-SHA256) aktiviert.");
|
||||||
|
println!(" • Replay-Schutz für Chunks (24-Byte AAD mit Generation) aktiviert.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_sync(
|
fn handle_sync(
|
||||||
container_path: &Path,
|
container_path: &Path,
|
||||||
source: &str,
|
source: &str,
|
||||||
target: &str,
|
target: &str,
|
||||||
pull: bool,
|
pull: bool,
|
||||||
delete: bool,
|
delete: bool,
|
||||||
|
delete_excluded: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
checksum: bool,
|
checksum: bool,
|
||||||
exclude: Vec<String>,
|
exclude: Vec<String>,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
|
force: bool,
|
||||||
|
backup: bool,
|
||||||
|
update: bool,
|
||||||
recovery_key: Option<&str>,
|
recovery_key: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
@@ -1271,10 +1432,14 @@ fn handle_sync(
|
|||||||
let options = sanctum::sync::SyncOptions {
|
let options = sanctum::sync::SyncOptions {
|
||||||
direction,
|
direction,
|
||||||
delete,
|
delete,
|
||||||
|
delete_excluded,
|
||||||
dry_run,
|
dry_run,
|
||||||
checksum,
|
checksum,
|
||||||
exclude_patterns: exclude,
|
exclude_patterns: exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
@@ -1338,6 +1503,24 @@ fn handle_sync(
|
|||||||
" • Bereits aktuell: {} Dateien (übersprungen)",
|
" • Bereits aktuell: {} Dateien (übersprungen)",
|
||||||
stats.files_skipped
|
stats.files_skipped
|
||||||
);
|
);
|
||||||
|
if stats.files_skipped_symlinks > 0 {
|
||||||
|
println!(
|
||||||
|
" • Symlinks: {} übersprungen",
|
||||||
|
stats.files_skipped_symlinks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if stats.files_skipped_invalid > 0 {
|
||||||
|
println!(
|
||||||
|
" • Ungültige Namen: {} übersprungen",
|
||||||
|
stats.files_skipped_invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if stats.files_backed_up > 0 {
|
||||||
|
println!(
|
||||||
|
" • Backups: {} Sicherheitskopien (.bak)",
|
||||||
|
stats.files_backed_up
|
||||||
|
);
|
||||||
|
}
|
||||||
if delete {
|
if delete {
|
||||||
println!(
|
println!(
|
||||||
" • Zu löschen: {} Dateien/Ordner",
|
" • Zu löschen: {} Dateien/Ordner",
|
||||||
@@ -1365,6 +1548,24 @@ fn handle_sync(
|
|||||||
" • Übersprungen: {} Dateien (bereits aktuell)",
|
" • Übersprungen: {} Dateien (bereits aktuell)",
|
||||||
stats.files_skipped
|
stats.files_skipped
|
||||||
);
|
);
|
||||||
|
if stats.files_skipped_symlinks > 0 {
|
||||||
|
println!(
|
||||||
|
" • Symlinks: {} übersprungen",
|
||||||
|
stats.files_skipped_symlinks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if stats.files_skipped_invalid > 0 {
|
||||||
|
println!(
|
||||||
|
" • Ungültige Namen: {} übersprungen",
|
||||||
|
stats.files_skipped_invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if stats.files_backed_up > 0 {
|
||||||
|
println!(
|
||||||
|
" • Backups: {} Sicherheitskopien (.bak)",
|
||||||
|
stats.files_backed_up
|
||||||
|
);
|
||||||
|
}
|
||||||
if delete {
|
if delete {
|
||||||
println!(
|
println!(
|
||||||
" • Gelöscht: {} verwaiste Dateien/Ordner",
|
" • Gelöscht: {} verwaiste Dateien/Ordner",
|
||||||
@@ -1421,6 +1622,7 @@ async fn run() -> Result<()> {
|
|||||||
idle_timeout,
|
idle_timeout,
|
||||||
no_screen_lock,
|
no_screen_lock,
|
||||||
no_anti_leak,
|
no_anti_leak,
|
||||||
|
anti_leak_list,
|
||||||
stealth,
|
stealth,
|
||||||
} => {
|
} => {
|
||||||
let drive_char = match drive {
|
let drive_char = match drive {
|
||||||
@@ -1473,6 +1675,7 @@ async fn run() -> Result<()> {
|
|||||||
idle_timeout,
|
idle_timeout,
|
||||||
lock_on_screen_lock,
|
lock_on_screen_lock,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
anti_leak_list.as_deref(),
|
||||||
stealth,
|
stealth,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1514,16 +1717,26 @@ async fn run() -> Result<()> {
|
|||||||
Commands::Verify { path, full } => {
|
Commands::Verify { path, full } => {
|
||||||
handle_verify(&path, full)?;
|
handle_verify(&path, full)?;
|
||||||
}
|
}
|
||||||
|
Commands::UpgradeFormat { path } => {
|
||||||
|
handle_upgrade_format(&path)?;
|
||||||
|
}
|
||||||
|
Commands::Rekey { path } => {
|
||||||
|
handle_rekey(&path)?;
|
||||||
|
}
|
||||||
Commands::Sync {
|
Commands::Sync {
|
||||||
path,
|
path,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
pull,
|
pull,
|
||||||
delete,
|
delete,
|
||||||
|
delete_excluded,
|
||||||
dry_run,
|
dry_run,
|
||||||
checksum,
|
checksum,
|
||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
recovery_key,
|
recovery_key,
|
||||||
} => {
|
} => {
|
||||||
handle_sync(
|
handle_sync(
|
||||||
@@ -1532,10 +1745,14 @@ async fn run() -> Result<()> {
|
|||||||
&target,
|
&target,
|
||||||
pull,
|
pull,
|
||||||
delete,
|
delete,
|
||||||
|
delete_excluded,
|
||||||
dry_run,
|
dry_run,
|
||||||
checksum,
|
checksum,
|
||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
recovery_key.as_deref(),
|
recovery_key.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-13
@@ -84,6 +84,7 @@ pub async fn mount_container(
|
|||||||
idle_timeout: Option<u64>,
|
idle_timeout: Option<u64>,
|
||||||
lock_on_screen_lock: bool,
|
lock_on_screen_lock: bool,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
|
anti_leak_list: Option<&Path>,
|
||||||
stealth: bool,
|
stealth: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let drive_str = format_drive(drive_letter);
|
let drive_str = format_drive(drive_letter);
|
||||||
@@ -158,9 +159,10 @@ pub async fn mount_container(
|
|||||||
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
|
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.");
|
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
|
||||||
}
|
}
|
||||||
let decoy_pass =
|
let decoy_pass = Zeroizing::new(
|
||||||
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 Standard-Vault Passworts")?;
|
.context("Fehler beim Einlesen des Standard-Vault Passworts")?,
|
||||||
|
);
|
||||||
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
|
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
|
||||||
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
|
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
|
||||||
})?;
|
})?;
|
||||||
@@ -182,21 +184,46 @@ pub async fn mount_container(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren
|
||||||
|
let _advisory_lock = db.acquire_advisory_lock_guard(false)?;
|
||||||
|
|
||||||
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
|
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
|
||||||
let mut token_bytes = [0u8; 16];
|
let mut token_bytes = [0u8; 16];
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||||
let session_token = hex::encode(token_bytes);
|
let session_token = hex::encode(token_bytes);
|
||||||
|
|
||||||
|
// Benutzerdefinierte Anti-Leak Filterregeln laden (V-04)
|
||||||
|
let custom_leak_rules = if let Some(path) = anti_leak_list {
|
||||||
|
match crate::vfs::load_anti_leak_list(path) {
|
||||||
|
Ok(rules) => {
|
||||||
|
if !stealth {
|
||||||
|
println!(
|
||||||
|
" • Anti-Leak Liste: {} benutzerdefinierte Regeln geladen",
|
||||||
|
rules.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rules
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
bail!("Fehler beim Laden der Anti-Leak-Regeln: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
||||||
let fs = SanctumFs::with_carrier(
|
let fs = SanctumFs::with_carrier_and_leak_rules(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
dek,
|
dek,
|
||||||
carrier_dek,
|
carrier_dek,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
version,
|
version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules,
|
||||||
vault_id,
|
vault_id,
|
||||||
);
|
);
|
||||||
|
let fs_leak_counter = fs.leak_counter();
|
||||||
let last_activity = fs.last_activity();
|
let last_activity = fs.last_activity();
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
@@ -372,16 +399,8 @@ pub async fn mount_container(
|
|||||||
);
|
);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
println!(
|
for line in format_linux_mount_instructions(bound_port, &session_token, mount_point) {
|
||||||
" • gio Befehl: gio mount dav://127.0.0.1:{}/",
|
println!("{}", line);
|
||||||
bound_port
|
|
||||||
);
|
|
||||||
if let Some(mp) = mount_point {
|
|
||||||
println!(
|
|
||||||
" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}",
|
|
||||||
bound_port,
|
|
||||||
mp.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(secs) = idle_timeout {
|
if let Some(secs) = idle_timeout {
|
||||||
@@ -588,6 +607,14 @@ pub async fn mount_container(
|
|||||||
println!("{}", ui::green("OK"));
|
println!("{}", ui::green("OK"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blocked_leaks = fs_leak_counter.load(Ordering::Relaxed);
|
||||||
|
if blocked_leaks > 0 {
|
||||||
|
println!(
|
||||||
|
" • Anti-Leak Shield: {} Zugriffe auf Metadaten-/Leak-Dateien blockiert.",
|
||||||
|
ui::cyan(&blocked_leaks.to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
|
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
|
||||||
@@ -654,6 +681,36 @@ pub fn is_loopback_host(host_str: &str) -> bool {
|
|||||||
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erzeugt die plattformspezifischen Linux-Mount-Anweisungen und Authentifizierungsdaten (M-03).
|
||||||
|
pub fn format_linux_mount_instructions(
|
||||||
|
port: u16,
|
||||||
|
token: &str,
|
||||||
|
mount_point: Option<&std::path::Path>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(format!(
|
||||||
|
" • WebDAV-Auth: Benutzer: sanctum | Token: {}",
|
||||||
|
token
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" • gio Befehl: echo {} | gio mount dav://sanctum@127.0.0.1:{}/",
|
||||||
|
token, port
|
||||||
|
));
|
||||||
|
if let Some(mp) = mount_point {
|
||||||
|
lines.push(format!(
|
||||||
|
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ {}",
|
||||||
|
port,
|
||||||
|
mp.display()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
lines.push(format!(
|
||||||
|
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ /mnt/sanctum",
|
||||||
|
port
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||||
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
||||||
|
|
||||||
@@ -945,4 +1002,18 @@ mod tests {
|
|||||||
// Leeres Token darf niemals matchen
|
// Leeres Token darf niemals matchen
|
||||||
assert!(!uri_contains_token(&uri_clean, ""));
|
assert!(!uri_contains_token(&uri_clean, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m03_format_linux_mount_instructions() {
|
||||||
|
let token = "fedcba9876543210fedcba9876543210";
|
||||||
|
let instructions =
|
||||||
|
format_linux_mount_instructions(8443, token, Some(std::path::Path::new("/mnt/secure")));
|
||||||
|
|
||||||
|
let all_text = instructions.join("\n");
|
||||||
|
assert!(all_text.contains("Benutzer: sanctum"));
|
||||||
|
assert!(all_text.contains(token));
|
||||||
|
assert!(all_text.contains("gio mount dav://sanctum@127.0.0.1:8443/"));
|
||||||
|
assert!(all_text
|
||||||
|
.contains("mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/secure"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
/// Prüft, ob ein Name Path-Traversal-Muster oder Pfadtrennzeichen enthält (S-02).
|
||||||
|
pub fn is_path_traversal(name: &str) -> bool {
|
||||||
|
name.is_empty()
|
||||||
|
|| name == "."
|
||||||
|
|| name == ".."
|
||||||
|
|| name.contains('/')
|
||||||
|
|| name.contains('\\')
|
||||||
|
|| name.contains('\0')
|
||||||
|
}
|
||||||
|
|
||||||
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
||||||
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
||||||
pub fn validate_node_name(name: &str) -> Result<()> {
|
pub fn validate_node_name(name: &str) -> Result<()> {
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) ->
|
|||||||
&chunk0.nonce,
|
&chunk0.nonce,
|
||||||
&chunk0.tag,
|
&chunk0.tag,
|
||||||
version,
|
version,
|
||||||
|
chunk0.generation,
|
||||||
)
|
)
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if is_dek0 {
|
if is_dek0 {
|
||||||
@@ -426,6 +427,73 @@ pub fn restore_header_from_recovery_key(
|
|||||||
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
|
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt ein vollständiges Rekeying eines Containers durch (K-03).
|
||||||
|
/// Generiert einen neuen kryptografischen DEK, verschlüsselt alle Chunks um,
|
||||||
|
/// verpackt den neuen DEK mit dem bestehenden Passwort und gibt das neue
|
||||||
|
/// 24-Wort Notfallblatt (BIP-39 Mnemonic) zurück.
|
||||||
|
/// Das alte Notfallblatt wird dadurch unwiderruflich ungültig.
|
||||||
|
pub fn rekey_container(container_path: &Path, password: &str) -> Result<String> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
let meta = db
|
||||||
|
.read_meta()
|
||||||
|
.context("Konnte Container-Header nicht lesen")?;
|
||||||
|
let keys = meta.authenticate(password).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Ungültiges Master-Passwort! Authentifizierung fehlgeschlagen.")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let old_dek = keys.dek().clone();
|
||||||
|
let slot_id = keys.slot_id();
|
||||||
|
let version = keys.version();
|
||||||
|
let carrier_dek = keys.carrier_dek();
|
||||||
|
let carrier_node_id = keys.carrier_node_id();
|
||||||
|
|
||||||
|
// 1. Neuen DEK generieren
|
||||||
|
let new_dek = crate::crypto::generate_dek();
|
||||||
|
|
||||||
|
// 2. Chunks des aktiven Vaults umverschlüsseln
|
||||||
|
db.rekey_vault(slot_id, &old_dek, &new_dek, version)?;
|
||||||
|
|
||||||
|
// 3. Neuen KEK ableiten und DEK neu verpacken
|
||||||
|
let new_salt = crate::crypto::generate_salt();
|
||||||
|
let new_params = KdfParams::default();
|
||||||
|
let new_kek = derive_kek(password, &new_salt, &new_params)?;
|
||||||
|
|
||||||
|
let (new_wrapped_dek, new_nonce, new_tag) = if slot_id == 1 {
|
||||||
|
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);
|
||||||
|
wrap_slot1_payload(&new_kek, &new_dek, &c_dek, c_nid)?
|
||||||
|
} else {
|
||||||
|
let c_nid = carrier_node_id.unwrap_or(0);
|
||||||
|
wrap_slot0_payload(&new_kek, &new_dek, c_nid)?
|
||||||
|
};
|
||||||
|
|
||||||
|
db.update_slot_keys(
|
||||||
|
slot_id,
|
||||||
|
&new_salt,
|
||||||
|
&new_params,
|
||||||
|
&new_wrapped_dek,
|
||||||
|
&new_nonce,
|
||||||
|
&new_tag,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 4. Metadaten-MAC mit neuem DEK aktualisieren
|
||||||
|
db.set_active_slot_and_dek(slot_id, new_dek.clone());
|
||||||
|
db.update_metadata_mac()?;
|
||||||
|
db.checkpoint()?;
|
||||||
|
|
||||||
|
// 5. Neues BIP-39 Notfallblatt generieren
|
||||||
|
let new_phrase = crate::crypto::dek_to_mnemonic(&new_dek)?;
|
||||||
|
Ok(new_phrase)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+852
-62
File diff suppressed because it is too large
Load Diff
+489
-55
@@ -13,7 +13,7 @@ use crate::storage::{Database, NodeRecord};
|
|||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::vfs::is_leak_file;
|
use crate::vfs::is_leak_file;
|
||||||
|
|
||||||
pub use crate::pathutil::validate_node_name;
|
pub use crate::pathutil::{is_path_traversal, validate_node_name};
|
||||||
|
|
||||||
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
|
/// 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> {
|
fn calc_local_file_sha256(path: &Path) -> Result<String> {
|
||||||
@@ -57,6 +57,7 @@ fn calc_vault_node_sha256(
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
version,
|
version,
|
||||||
|
record.generation,
|
||||||
)?;
|
)?;
|
||||||
hasher.update(&decrypted);
|
hasher.update(&decrypted);
|
||||||
}
|
}
|
||||||
@@ -77,10 +78,14 @@ pub enum SyncDirection {
|
|||||||
pub struct SyncOptions {
|
pub struct SyncOptions {
|
||||||
pub direction: SyncDirection,
|
pub direction: SyncDirection,
|
||||||
pub delete: bool,
|
pub delete: bool,
|
||||||
|
pub delete_excluded: bool,
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
pub checksum: bool,
|
pub checksum: bool,
|
||||||
pub exclude_patterns: Vec<String>,
|
pub exclude_patterns: Vec<String>,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
|
pub force: bool,
|
||||||
|
pub backup: bool,
|
||||||
|
pub update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SyncOptions {
|
impl Default for SyncOptions {
|
||||||
@@ -88,10 +93,14 @@ impl Default for SyncOptions {
|
|||||||
Self {
|
Self {
|
||||||
direction: SyncDirection::Push,
|
direction: SyncDirection::Push,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
delete_excluded: false,
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
force: false,
|
||||||
|
backup: false,
|
||||||
|
update: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +111,9 @@ pub struct SyncStats {
|
|||||||
pub files_scanned: usize,
|
pub files_scanned: usize,
|
||||||
pub files_transferred: usize,
|
pub files_transferred: usize,
|
||||||
pub files_skipped: usize,
|
pub files_skipped: usize,
|
||||||
|
pub files_skipped_symlinks: usize,
|
||||||
|
pub files_skipped_invalid: usize,
|
||||||
|
pub files_backed_up: usize,
|
||||||
pub files_deleted: usize,
|
pub files_deleted: usize,
|
||||||
pub bytes_transferred: u64,
|
pub bytes_transferred: u64,
|
||||||
pub elapsed: Duration,
|
pub elapsed: Duration,
|
||||||
@@ -115,14 +127,91 @@ pub enum FileTransferResult {
|
|||||||
DryRunTransferred { size: u64 },
|
DryRunTransferred { size: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht.
|
/// Einfacher, effizienter Glob-Matcher ohne externe Abhängigkeiten (unterstützt `*`, `**` und `?`).
|
||||||
|
pub fn glob_match(pattern: &str, text: &str) -> bool {
|
||||||
|
glob_match_slice(pattern.as_bytes(), text.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_match_slice(pat: &[u8], text: &[u8]) -> bool {
|
||||||
|
if pat.is_empty() {
|
||||||
|
return text.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-Star '**': Entspricht 0 oder mehr beliebigen Zeichen inklusive Verzeichnistrenner
|
||||||
|
if pat.starts_with(b"**") {
|
||||||
|
let rest_pat = if pat.len() > 2 && pat[2] == b'/' {
|
||||||
|
&pat[3..]
|
||||||
|
} else {
|
||||||
|
&pat[2..]
|
||||||
|
};
|
||||||
|
if rest_pat.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for i in 0..=text.len() {
|
||||||
|
if glob_match_slice(rest_pat, &text[i..]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-Star '*': Entspricht 0 oder mehr Zeichen innerhalb desselben Pfadsegments (stoppt an '/')
|
||||||
|
if pat[0] == b'*' {
|
||||||
|
let rest_pat = &pat[1..];
|
||||||
|
for i in 0..=text.len() {
|
||||||
|
if i > 0 && (text[i - 1] == b'/' || text[i - 1] == b'\\') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if glob_match_slice(rest_pat, &text[i..]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question mark '?': Einzelzeichen-Platzhalter (außer Pfadtrenner)
|
||||||
|
if pat[0] == b'?' {
|
||||||
|
if text[0] == b'/' || text[0] == b'\\' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return glob_match_slice(&pat[1..], &text[1..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pat[0] == text[0] {
|
||||||
|
return glob_match_slice(&pat[1..], &text[1..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht (S-08).
|
||||||
|
///
|
||||||
|
/// Unterstützte Musterformate:
|
||||||
|
/// 1. Anti-Leak Shield: OS- und Explorer-Metadaten (z. B. Thumbs.db, .DS_Store) werden stets ausgeschlossen.
|
||||||
|
/// 2. Glob-Muster mit Wildcards:
|
||||||
|
/// - `*.ext`: Schließt alle Dateien mit dieser Endung im gesamten Baum aus (z. B. `*.tmp`, `*.bak`).
|
||||||
|
/// - `prefix*`: Schließt alle Dateien aus, deren Name mit dem Präfix beginnt (z. B. `temp_*`, `backup*`).
|
||||||
|
/// - `*middle*`: Schließt Dateien/Pfade aus, die die Zeichenkette enthalten.
|
||||||
|
/// - `?`: Einzelzeichen-Platzhalter (z. B. `file?.txt`).
|
||||||
|
/// 3. Verzeichnis-Ausschlüsse:
|
||||||
|
/// - `dirname/` oder `dirname`: Schließt das Verzeichnis und alle darin enthaltenen Dateien/Unterordner aus.
|
||||||
|
/// - `/path/to/dir`: Verankert den Ausschluss relativ zum Synchronisations-Wurzelverzeichnis.
|
||||||
|
/// 4. Pfadspezifische Muster:
|
||||||
|
/// - `build/*.bin`: Schließt `.bin`-Dateien im Ordner `build` aus.
|
||||||
pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||||
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
|
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
|
||||||
if is_leak_file(name) {
|
if is_leak_file(name) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let norm_rel = rel_path.replace('\\', "/");
|
let norm_rel = rel_path
|
||||||
|
.replace('\\', "/")
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.to_ascii_lowercase();
|
||||||
let norm_name = name.to_ascii_lowercase();
|
let norm_name = name.to_ascii_lowercase();
|
||||||
|
|
||||||
for pat in patterns {
|
for pat in patterns {
|
||||||
@@ -131,32 +220,45 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wildcard-Muster: *.ext
|
let p_norm = p.replace('\\', "/").to_ascii_lowercase();
|
||||||
if p.starts_with("*.") {
|
|
||||||
let ext = &p[1..].to_ascii_lowercase();
|
// Verzeichnis-Ausschluss mit nachgestelltem Slash (z. B. "logs/" oder "build/temp/")
|
||||||
if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) {
|
if p_norm.ends_with('/') {
|
||||||
return true;
|
let dir_prefix = p_norm.trim_matches('/');
|
||||||
}
|
if norm_rel == dir_prefix
|
||||||
}
|
|| norm_rel.starts_with(&format!("{}/", dir_prefix))
|
||||||
// Wildcard-Muster: prefix*
|
|| norm_rel.contains(&format!("/{}/", dir_prefix))
|
||||||
else if p.ends_with('*') && !p[..p.len() - 1].contains('*') {
|
|
||||||
let prefix = p[..p.len() - 1].to_ascii_lowercase();
|
|
||||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix)
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exakter Name oder Teilpfad
|
|
||||||
else {
|
// Muster mit Pfadtrennzeichen (relativ verankert oder spezifischer Unterpfad, z. B. "/logs", "sub/*.txt")
|
||||||
let p_lower = p.to_ascii_lowercase();
|
if p_norm.contains('/') {
|
||||||
if norm_name == p_lower {
|
let p_clean = p_norm.trim_start_matches('/');
|
||||||
|
if glob_match(p_clean, &norm_rel) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase()
|
// Wenn p_clean ein Verzeichnis ohne Wildcards ist, auch alle Unterpfade erfassen
|
||||||
== p_lower.trim_start_matches('/')
|
if !p_clean.contains('*')
|
||||||
|
&& !p_clean.contains('?')
|
||||||
|
&& (norm_rel == p_clean || norm_rel.starts_with(&format!("{}/", p_clean)))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Reines Dateinamen- oder Segment-Muster ohne '/' (z. B. "*.tmp", "temp_*", "node_modules")
|
||||||
|
if glob_match(&p_norm, &norm_name) || glob_match(&p_norm, &norm_rel) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Wenn p_norm ein exakter Ordnername ist (z. B. "node_modules", ".git"), alle Pfade mit diesem Segment erfassen
|
||||||
|
if !p_norm.contains('*') && !p_norm.contains('?') {
|
||||||
|
for seg in norm_rel.split('/') {
|
||||||
|
if seg == p_norm {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,12 +413,15 @@ pub fn sync_single_file_to_vault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let chunk_data = &buffer[..n];
|
let chunk_data = &buffer[..n];
|
||||||
let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version)?;
|
let gen = db.next_chunk_generation(node_id, chunk_idx)?;
|
||||||
|
let (ciphertext, nonce, tag) =
|
||||||
|
encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version, gen)?;
|
||||||
bytes_written += n as u64;
|
bytes_written += n as u64;
|
||||||
|
|
||||||
db.write_chunk_and_update_size(
|
db.write_chunk_and_update_size(
|
||||||
node_id,
|
node_id,
|
||||||
chunk_idx,
|
chunk_idx,
|
||||||
|
gen,
|
||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
&ciphertext,
|
&ciphertext,
|
||||||
@@ -328,27 +433,68 @@ pub fn sync_single_file_to_vault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen
|
// Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen
|
||||||
|
if bytes_written == 0 {
|
||||||
|
// S-05: 0-Byte Datei: Alle Chunks (inkl. Chunk 0) restlos löschen und shreddern
|
||||||
|
db.delete_all_chunks(node_id)?;
|
||||||
|
} else {
|
||||||
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
|
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
|
||||||
db.update_node_size_and_time(node_id, local_size, local_mtime)?;
|
}
|
||||||
|
// S-04: Verwende bytes_written statt local_size zur Vermeidung von TOCTOU-Diskrepanzen
|
||||||
|
db.update_node_size_and_time(node_id, bytes_written, local_mtime)?;
|
||||||
|
|
||||||
Ok(FileTransferResult::Transferred { size: local_size })
|
Ok(FileTransferResult::Transferred {
|
||||||
|
size: bytes_written,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull).
|
/// Erstellt einen sicheren Pfad für Sicherungskopien (.bak) überschriebener Dateien (S-09).
|
||||||
pub fn sync_single_file_to_host(
|
pub fn make_backup_path(path: &Path) -> std::path::PathBuf {
|
||||||
|
let mut bak = path.to_path_buf();
|
||||||
|
let ext = bak
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let new_ext = if ext.is_empty() {
|
||||||
|
"bak".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.bak", ext)
|
||||||
|
};
|
||||||
|
bak.set_extension(new_ext);
|
||||||
|
if !bak.exists() {
|
||||||
|
return bak;
|
||||||
|
}
|
||||||
|
for i in 1..=1000 {
|
||||||
|
let mut numbered = path.to_path_buf();
|
||||||
|
let numbered_ext = if ext.is_empty() {
|
||||||
|
format!("bak.{}", i)
|
||||||
|
} else {
|
||||||
|
format!("{}.bak.{}", ext, i)
|
||||||
|
};
|
||||||
|
numbered.set_extension(numbered_ext);
|
||||||
|
if !numbered.exists() {
|
||||||
|
return numbered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bak
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte mit atomarem Schreiben,
|
||||||
|
/// optionalem .bak-Sicherheits-Backup und Konfliktschutz (S-09, Pull).
|
||||||
|
pub fn sync_single_file_to_host_opts(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
_vault_id: u32,
|
_vault_id: u32,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
version: u32,
|
version: u32,
|
||||||
node: &NodeRecord,
|
node: &NodeRecord,
|
||||||
local_path: &Path,
|
local_path: &Path,
|
||||||
checksum: bool,
|
options: &SyncOptions,
|
||||||
dry_run: bool,
|
stats: &mut SyncStats,
|
||||||
) -> Result<FileTransferResult> {
|
) -> Result<FileTransferResult> {
|
||||||
db.assert_not_carrier(node.id)?;
|
db.assert_not_carrier(node.id)?;
|
||||||
validate_node_name(&node.name)?;
|
validate_node_name(&node.name)?;
|
||||||
|
|
||||||
if local_path.exists() {
|
let local_exists = local_path.exists();
|
||||||
|
if local_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();
|
||||||
let local_mtime = meta
|
let local_mtime = meta
|
||||||
@@ -358,12 +504,13 @@ pub fn sync_single_file_to_host(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
if !checksum && local_size == node.size && local_mtime == node.modified_at {
|
// Identische Datei: Überspringen
|
||||||
|
if !options.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)
|
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
|
||||||
if checksum && local_size == node.size {
|
if options.checksum && local_size == node.size {
|
||||||
if let (Ok(local_hash), Ok(vault_hash)) = (
|
if let (Ok(local_hash), Ok(vault_hash)) = (
|
||||||
calc_local_file_sha256(local_path),
|
calc_local_file_sha256(local_path),
|
||||||
calc_vault_node_sha256(db, node, dek, version),
|
calc_vault_node_sha256(db, node, dek, version),
|
||||||
@@ -373,10 +520,28 @@ pub fn sync_single_file_to_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S-09: Konfliktschutz (--update)
|
||||||
|
// Wenn die lokale Datei neuer ist als die Version im Tresor,
|
||||||
|
// verweigere das Überschreiben, sofern nicht --force gesetzt ist.
|
||||||
|
if options.update && !options.force && local_mtime > node.modified_at {
|
||||||
|
if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Lokale Datei ist neuer ({}) - übersprungen mit --update: {}",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
local_mtime,
|
||||||
|
local_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(FileTransferResult::Skipped { size: node.size });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if options.dry_run {
|
||||||
|
if local_exists && options.backup {
|
||||||
|
stats.files_backed_up += 1;
|
||||||
|
}
|
||||||
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
|
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,10 +549,20 @@ pub fn sync_single_file_to_host(
|
|||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out_file = File::create(local_path).with_context(|| {
|
// S-09: Atomares Schreiben über temporäre Datei im selben Verzeichnis
|
||||||
|
// Verhindert inkonsistente / beschädigte Zieldateien bei Abbruch oder I/O-Fehlern
|
||||||
|
let parent_dir = local_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let temp_path = parent_dir.join(format!(
|
||||||
|
".sanctum_pull_{}_{}.tmp",
|
||||||
|
std::process::id(),
|
||||||
|
rand::random::<u64>()
|
||||||
|
));
|
||||||
|
|
||||||
|
let write_res = (|| -> Result<()> {
|
||||||
|
let mut out_file = File::create(&temp_path).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Konnte Zieldatei '{}' nicht erstellen",
|
"Konnte temporäre Zieldatei '{}' nicht erstellen",
|
||||||
local_path.display()
|
temp_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -407,6 +582,7 @@ pub fn sync_single_file_to_host(
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
version,
|
version,
|
||||||
|
record.generation,
|
||||||
)?;
|
)?;
|
||||||
out_file.write_all(&plaintext)?;
|
out_file.write_all(&plaintext)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -420,10 +596,76 @@ pub fn sync_single_file_to_host(
|
|||||||
|
|
||||||
out_file.flush()?;
|
out_file.flush()?;
|
||||||
set_local_file_mtime(&out_file, node.modified_at);
|
set_local_file_mtime(&out_file, node.modified_at);
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = write_res {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// S-09: Backup-Erstellung bei bestehender Zieldatei (--backup)
|
||||||
|
if local_exists && options.backup {
|
||||||
|
let backup_path = make_backup_path(local_path);
|
||||||
|
fs::rename(local_path, &backup_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Konnte bestehende Zieldatei nicht nach '{}' sichern",
|
||||||
|
backup_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
stats.files_backed_up += 1;
|
||||||
|
if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Backup erstellt: {}",
|
||||||
|
ui::cyan("[*]"),
|
||||||
|
backup_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomares Ersetzen der Zieldatei
|
||||||
|
if local_path.exists() {
|
||||||
|
fs::remove_file(local_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Konnte bestehende Zieldatei '{}' vor atomarem Verschieben nicht entfernen",
|
||||||
|
local_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&temp_path, local_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Konnte temporäre Datei '{}' nicht nach '{}' verschieben",
|
||||||
|
temp_path.display(),
|
||||||
|
local_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(FileTransferResult::Transferred { size: node.size })
|
Ok(FileTransferResult::Transferred { size: node.size })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abwärtskompatibler Wrapper für sync_single_file_to_host.
|
||||||
|
pub fn sync_single_file_to_host(
|
||||||
|
db: &Database,
|
||||||
|
vault_id: u32,
|
||||||
|
dek: &[u8; 32],
|
||||||
|
version: u32,
|
||||||
|
node: &NodeRecord,
|
||||||
|
local_path: &Path,
|
||||||
|
checksum: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<FileTransferResult> {
|
||||||
|
let mut stats = SyncStats::default();
|
||||||
|
let options = SyncOptions {
|
||||||
|
checksum,
|
||||||
|
dry_run,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
sync_single_file_to_host_opts(
|
||||||
|
db, vault_id, dek, version, node, local_path, &options, &mut stats,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Führt die vollständige Synchronisation zwischen Host und Container aus.
|
/// Führt die vollständige Synchronisation zwischen Host und Container aus.
|
||||||
pub fn run_sync(
|
pub fn run_sync(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -437,6 +679,25 @@ pub fn run_sync(
|
|||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
let mut stats = SyncStats::default();
|
let mut stats = SyncStats::default();
|
||||||
|
|
||||||
|
// S-06: Advisory-Lock gegen parallelen Mount / gleichzeitigen Zugriff
|
||||||
|
if let Some((pid, host, time)) = db.check_advisory_lock()? {
|
||||||
|
if !options.force {
|
||||||
|
bail!(
|
||||||
|
"Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.",
|
||||||
|
pid, host, time
|
||||||
|
);
|
||||||
|
} else if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Warnung: Aktiver Advisory-Lock (Prozess {} auf '{}') wird durch --force überschrieben.",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
pid,
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.set_active_dek(zeroize::Zeroizing::new(*dek));
|
||||||
|
|
||||||
match options.direction {
|
match options.direction {
|
||||||
SyncDirection::Push => {
|
SyncDirection::Push => {
|
||||||
sync_push(
|
sync_push(
|
||||||
@@ -476,6 +737,21 @@ fn sync_push(
|
|||||||
target_str
|
target_str
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let sym_meta = fs::symlink_metadata(local_source)?;
|
||||||
|
if sym_meta.file_type().is_symlink() {
|
||||||
|
stats.files_scanned += 1;
|
||||||
|
stats.files_skipped += 1;
|
||||||
|
stats.files_skipped_symlinks += 1;
|
||||||
|
if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Symlink übersprungen: {}",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
source_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if local_source.is_file() {
|
if local_source.is_file() {
|
||||||
let file_name = local_source
|
let file_name = local_source
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -537,6 +813,7 @@ fn sync_push(
|
|||||||
} else if local_source.is_dir() {
|
} else if local_source.is_dir() {
|
||||||
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
|
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
|
||||||
let mut local_relative_paths = HashSet::new();
|
let mut local_relative_paths = HashSet::new();
|
||||||
|
let mut excluded_relative_paths = HashSet::new();
|
||||||
|
|
||||||
// Rekursiv alle lokalen Dateien und Ordner erfassen
|
// Rekursiv alle lokalen Dateien und Ordner erfassen
|
||||||
collect_and_push_dir(
|
collect_and_push_dir(
|
||||||
@@ -550,6 +827,7 @@ fn sync_push(
|
|||||||
options,
|
options,
|
||||||
stats,
|
stats,
|
||||||
&mut local_relative_paths,
|
&mut local_relative_paths,
|
||||||
|
&mut excluded_relative_paths,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen
|
// Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen
|
||||||
@@ -561,6 +839,9 @@ fn sync_push(
|
|||||||
parent_node.id,
|
parent_node.id,
|
||||||
"",
|
"",
|
||||||
&local_relative_paths,
|
&local_relative_paths,
|
||||||
|
&excluded_relative_paths,
|
||||||
|
&options.exclude_patterns,
|
||||||
|
options.delete_excluded,
|
||||||
options.dry_run,
|
options.dry_run,
|
||||||
options.quiet,
|
options.quiet,
|
||||||
stats,
|
stats,
|
||||||
@@ -582,12 +863,32 @@ fn collect_and_push_dir(
|
|||||||
options: &SyncOptions,
|
options: &SyncOptions,
|
||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
local_relative_paths: &mut HashSet<String>,
|
local_relative_paths: &mut HashSet<String>,
|
||||||
|
excluded_relative_paths: &mut HashSet<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for entry in fs::read_dir(current_dir)? {
|
for entry in fs::read_dir(current_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)?;
|
if is_path_traversal(&file_name) {
|
||||||
|
bail!(
|
||||||
|
"Path traversal Versuch erkannt in Dateinamen: '{}'",
|
||||||
|
file_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_node_name(&file_name) {
|
||||||
|
stats.files_scanned += 1;
|
||||||
|
stats.files_skipped += 1;
|
||||||
|
stats.files_skipped_invalid += 1;
|
||||||
|
if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Ungültiger Dateiname übersprungen: {} ({})",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
file_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(base_dir)
|
.strip_prefix(base_dir)
|
||||||
@@ -596,6 +897,23 @@ fn collect_and_push_dir(
|
|||||||
.replace('\\', "/");
|
.replace('\\', "/");
|
||||||
|
|
||||||
if is_excluded(&file_name, &rel_path, &options.exclude_patterns) {
|
if is_excluded(&file_name, &rel_path, &options.exclude_patterns) {
|
||||||
|
excluded_relative_paths.insert(rel_path.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// S-03: Symlinks standardmäßig überspringen (Schutz vor Zyklen und Rekursion)
|
||||||
|
let sym_meta = match fs::symlink_metadata(&path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if sym_meta.file_type().is_symlink() {
|
||||||
|
stats.files_scanned += 1;
|
||||||
|
stats.files_skipped += 1;
|
||||||
|
stats.files_skipped_symlinks += 1;
|
||||||
|
if !options.quiet {
|
||||||
|
println!(" {} Symlink übersprungen: {}", ui::yellow("[!]"), rel_path);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +962,7 @@ fn collect_and_push_dir(
|
|||||||
options,
|
options,
|
||||||
stats,
|
stats,
|
||||||
local_relative_paths,
|
local_relative_paths,
|
||||||
|
excluded_relative_paths,
|
||||||
)?;
|
)?;
|
||||||
} else if path.is_file() {
|
} else if path.is_file() {
|
||||||
stats.files_scanned += 1;
|
stats.files_scanned += 1;
|
||||||
@@ -702,6 +1021,9 @@ pub fn delete_orphans_in_vault(
|
|||||||
current_vault_id: i64,
|
current_vault_id: i64,
|
||||||
prefix_rel: &str,
|
prefix_rel: &str,
|
||||||
local_relative_paths: &HashSet<String>,
|
local_relative_paths: &HashSet<String>,
|
||||||
|
excluded_relative_paths: &HashSet<String>,
|
||||||
|
exclude_patterns: &[String],
|
||||||
|
delete_excluded: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
@@ -724,6 +1046,19 @@ pub fn delete_orphans_in_vault(
|
|||||||
format!("{}/{}", prefix_rel, child.name)
|
format!("{}/{}", prefix_rel, child.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen
|
||||||
|
if !delete_excluded {
|
||||||
|
if is_excluded(&child.name, &child_rel, exclude_patterns) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if excluded_relative_paths
|
||||||
|
.iter()
|
||||||
|
.any(|ex| child_rel == *ex || child_rel.starts_with(&format!("{}/", ex)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !local_relative_paths.contains(&child_rel) {
|
if !local_relative_paths.contains(&child_rel) {
|
||||||
stats.files_deleted += 1;
|
stats.files_deleted += 1;
|
||||||
if dry_run {
|
if dry_run {
|
||||||
@@ -748,6 +1083,9 @@ pub fn delete_orphans_in_vault(
|
|||||||
child.id,
|
child.id,
|
||||||
&child_rel,
|
&child_rel,
|
||||||
local_relative_paths,
|
local_relative_paths,
|
||||||
|
excluded_relative_paths,
|
||||||
|
exclude_patterns,
|
||||||
|
delete_excluded,
|
||||||
dry_run,
|
dry_run,
|
||||||
quiet,
|
quiet,
|
||||||
stats,
|
stats,
|
||||||
@@ -784,15 +1122,15 @@ fn sync_pull(
|
|||||||
};
|
};
|
||||||
|
|
||||||
stats.files_scanned += 1;
|
stats.files_scanned += 1;
|
||||||
match sync_single_file_to_host(
|
match sync_single_file_to_host_opts(
|
||||||
db,
|
db,
|
||||||
vault_id,
|
vault_id,
|
||||||
dek,
|
dek,
|
||||||
version,
|
version,
|
||||||
&vault_source_node,
|
&vault_source_node,
|
||||||
&local_file_path,
|
&local_file_path,
|
||||||
options.checksum,
|
options,
|
||||||
options.dry_run,
|
stats,
|
||||||
)? {
|
)? {
|
||||||
FileTransferResult::Transferred { size } => {
|
FileTransferResult::Transferred { size } => {
|
||||||
stats.files_transferred += 1;
|
stats.files_transferred += 1;
|
||||||
@@ -836,6 +1174,7 @@ fn sync_pull(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut vault_relative_paths = HashSet::new();
|
let mut vault_relative_paths = HashSet::new();
|
||||||
|
let mut excluded_relative_paths = HashSet::new();
|
||||||
|
|
||||||
collect_and_pull_dir(
|
collect_and_pull_dir(
|
||||||
db,
|
db,
|
||||||
@@ -848,6 +1187,7 @@ fn sync_pull(
|
|||||||
options,
|
options,
|
||||||
stats,
|
stats,
|
||||||
&mut vault_relative_paths,
|
&mut vault_relative_paths,
|
||||||
|
&mut excluded_relative_paths,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Spiegelung mit --delete: Lokale verwaiste Dateien entfernen
|
// Spiegelung mit --delete: Lokale verwaiste Dateien entfernen
|
||||||
@@ -856,6 +1196,9 @@ fn sync_pull(
|
|||||||
local_target_dir,
|
local_target_dir,
|
||||||
local_target_dir,
|
local_target_dir,
|
||||||
&vault_relative_paths,
|
&vault_relative_paths,
|
||||||
|
&excluded_relative_paths,
|
||||||
|
&options.exclude_patterns,
|
||||||
|
options.delete_excluded,
|
||||||
options.dry_run,
|
options.dry_run,
|
||||||
options.quiet,
|
options.quiet,
|
||||||
stats,
|
stats,
|
||||||
@@ -877,6 +1220,7 @@ fn collect_and_pull_dir(
|
|||||||
options: &SyncOptions,
|
options: &SyncOptions,
|
||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
vault_relative_paths: &mut HashSet<String>,
|
vault_relative_paths: &mut HashSet<String>,
|
||||||
|
excluded_relative_paths: &mut HashSet<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
|
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
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)?;
|
||||||
@@ -890,7 +1234,27 @@ fn collect_and_pull_dir(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_node_name(&child.name)?;
|
if is_path_traversal(&child.name) {
|
||||||
|
bail!(
|
||||||
|
"Path traversal Versuch erkannt in Knotennamen: '{}'",
|
||||||
|
child.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = validate_node_name(&child.name) {
|
||||||
|
stats.files_scanned += 1;
|
||||||
|
stats.files_skipped += 1;
|
||||||
|
stats.files_skipped_invalid += 1;
|
||||||
|
if !options.quiet {
|
||||||
|
println!(
|
||||||
|
" {} Ungültiger Dateiname im Tresor übersprungen: {} ({})",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
child.name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let child_rel = if rel_prefix.is_empty() {
|
let child_rel = if rel_prefix.is_empty() {
|
||||||
child.name.clone()
|
child.name.clone()
|
||||||
@@ -899,24 +1263,23 @@ fn collect_and_pull_dir(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if is_excluded(&child.name, &child_rel, &options.exclude_patterns) {
|
if is_excluded(&child.name, &child_rel, &options.exclude_patterns) {
|
||||||
|
excluded_relative_paths.insert(child_rel.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-08 Path Traversal Guard:
|
// S-02 & S-08 Path Traversal Guard:
|
||||||
// Prüfe Komponenten des relativen Pfads und stelle sicher, dass der Pfad nicht ausbricht
|
// Plattformunabhängiger Pfadaufbau mit Segment-Validierung
|
||||||
for comp in Path::new(&child_rel).components() {
|
let mut local_child_path = local_target_base.to_path_buf();
|
||||||
match comp {
|
for seg in child_rel.split('/') {
|
||||||
std::path::Component::Normal(_) => {}
|
if seg.is_empty() || seg == "." || seg == ".." || seg.contains('\\') {
|
||||||
_ => bail!(
|
bail!(
|
||||||
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
|
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
|
||||||
child_rel
|
child_rel
|
||||||
),
|
);
|
||||||
}
|
}
|
||||||
|
local_child_path.push(seg);
|
||||||
}
|
}
|
||||||
|
|
||||||
vault_relative_paths.insert(child_rel.clone());
|
|
||||||
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
|
||||||
|
|
||||||
if !local_child_path.starts_with(local_target_base) {
|
if !local_child_path.starts_with(local_target_base) {
|
||||||
bail!(
|
bail!(
|
||||||
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
|
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
|
||||||
@@ -924,6 +1287,8 @@ fn collect_and_pull_dir(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vault_relative_paths.insert(child_rel.clone());
|
||||||
|
|
||||||
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)?;
|
||||||
@@ -939,18 +1304,19 @@ fn collect_and_pull_dir(
|
|||||||
options,
|
options,
|
||||||
stats,
|
stats,
|
||||||
vault_relative_paths,
|
vault_relative_paths,
|
||||||
|
excluded_relative_paths,
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
stats.files_scanned += 1;
|
stats.files_scanned += 1;
|
||||||
match sync_single_file_to_host(
|
match sync_single_file_to_host_opts(
|
||||||
db,
|
db,
|
||||||
vault_id,
|
vault_id,
|
||||||
dek,
|
dek,
|
||||||
version,
|
version,
|
||||||
&child,
|
&child,
|
||||||
&local_child_path,
|
&local_child_path,
|
||||||
options.checksum,
|
options,
|
||||||
options.dry_run,
|
stats,
|
||||||
)? {
|
)? {
|
||||||
FileTransferResult::Transferred { size } => {
|
FileTransferResult::Transferred { size } => {
|
||||||
stats.files_transferred += 1;
|
stats.files_transferred += 1;
|
||||||
@@ -993,6 +1359,9 @@ fn delete_orphans_on_host(
|
|||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
current_dir: &Path,
|
current_dir: &Path,
|
||||||
vault_relative_paths: &HashSet<String>,
|
vault_relative_paths: &HashSet<String>,
|
||||||
|
excluded_relative_paths: &HashSet<String>,
|
||||||
|
exclude_patterns: &[String],
|
||||||
|
delete_excluded: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
@@ -1004,15 +1373,34 @@ fn delete_orphans_on_host(
|
|||||||
for entry in fs::read_dir(current_dir)? {
|
for entry in fs::read_dir(current_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 rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(base_dir)
|
.strip_prefix(base_dir)
|
||||||
.unwrap_or(&path)
|
.unwrap_or(&path)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.replace('\\', "/");
|
.replace('\\', "/");
|
||||||
|
|
||||||
|
// S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen
|
||||||
|
if !delete_excluded {
|
||||||
|
if is_excluded(&file_name, &rel_path, exclude_patterns) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if excluded_relative_paths
|
||||||
|
.iter()
|
||||||
|
.any(|ex| rel_path == *ex || rel_path.starts_with(&format!("{}/", ex)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_symlink = match fs::symlink_metadata(&path) {
|
||||||
|
Ok(m) => m.file_type().is_symlink(),
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
|
||||||
if !vault_relative_paths.contains(&rel_path) {
|
if !vault_relative_paths.contains(&rel_path) {
|
||||||
stats.files_deleted += 1;
|
stats.files_deleted += 1;
|
||||||
if path.is_dir() {
|
if path.is_dir() && !is_symlink {
|
||||||
if dry_run {
|
if dry_run {
|
||||||
if !quiet {
|
if !quiet {
|
||||||
println!(
|
println!(
|
||||||
@@ -1043,8 +1431,18 @@ fn delete_orphans_on_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if path.is_dir() {
|
} else if path.is_dir() && !is_symlink {
|
||||||
delete_orphans_on_host(base_dir, &path, vault_relative_paths, dry_run, quiet, stats)?;
|
delete_orphans_on_host(
|
||||||
|
base_dir,
|
||||||
|
&path,
|
||||||
|
vault_relative_paths,
|
||||||
|
excluded_relative_paths,
|
||||||
|
exclude_patterns,
|
||||||
|
delete_excluded,
|
||||||
|
dry_run,
|
||||||
|
quiet,
|
||||||
|
stats,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,6 +1453,20 @@ fn delete_orphans_on_host(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_glob_match() {
|
||||||
|
assert!(glob_match("*.txt", "hello.txt"));
|
||||||
|
assert!(glob_match("*.txt", ".txt"));
|
||||||
|
assert!(!glob_match("*.txt", "hello.doc"));
|
||||||
|
assert!(glob_match("temp_*", "temp_file.dat"));
|
||||||
|
assert!(!glob_match("temp_*", "other_temp_file.dat"));
|
||||||
|
assert!(glob_match("build/*.bin", "build/app.bin"));
|
||||||
|
assert!(!glob_match("build/*.bin", "build/debug/app.bin"));
|
||||||
|
assert!(glob_match("file?.txt", "file1.txt"));
|
||||||
|
assert!(!glob_match("file?.txt", "file12.txt"));
|
||||||
|
assert!(glob_match("*test*", "my_test_case"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_excluded_patterns() {
|
fn test_is_excluded_patterns() {
|
||||||
let patterns = vec![
|
let patterns = vec![
|
||||||
@@ -1063,6 +1475,10 @@ mod tests {
|
|||||||
"Thumbs.db".to_string(),
|
"Thumbs.db".to_string(),
|
||||||
"backup_*".to_string(),
|
"backup_*".to_string(),
|
||||||
"/logs".to_string(),
|
"/logs".to_string(),
|
||||||
|
"target/".to_string(),
|
||||||
|
"node_modules".to_string(),
|
||||||
|
"build/*.bin".to_string(),
|
||||||
|
"file?.doc".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
|
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
|
||||||
@@ -1073,11 +1489,29 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
|
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
|
||||||
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
|
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
|
||||||
|
assert!(is_excluded("app.log", "logs/app.log", &patterns));
|
||||||
|
assert!(is_excluded("deep.log", "logs/sub/deep.log", &patterns));
|
||||||
|
assert!(is_excluded("cache.bin", "target/cache.bin", &patterns));
|
||||||
|
assert!(is_excluded(
|
||||||
|
"package.json",
|
||||||
|
"node_modules/pkg/package.json",
|
||||||
|
&patterns
|
||||||
|
));
|
||||||
|
assert!(is_excluded(
|
||||||
|
"package.json",
|
||||||
|
"client/node_modules/pkg/package.json",
|
||||||
|
&patterns
|
||||||
|
));
|
||||||
|
assert!(is_excluded("app.bin", "build/app.bin", &patterns));
|
||||||
|
assert!(is_excluded("file1.doc", "sub/file1.doc", &patterns));
|
||||||
|
|
||||||
|
assert!(!is_excluded("file12.doc", "sub/file12.doc", &patterns));
|
||||||
assert!(!is_excluded(
|
assert!(!is_excluded(
|
||||||
"important.doc",
|
"important.doc",
|
||||||
"sub/important.doc",
|
"sub/important.doc",
|
||||||
&patterns
|
&patterns
|
||||||
));
|
));
|
||||||
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
|
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
|
||||||
|
assert!(!is_excluded("app.bin", "other/app.bin", &patterns));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-6
@@ -20,6 +20,8 @@ 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 CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||||
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
|
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
|
||||||
|
pub const SANCTUM_RELEASE_PUBKEY_BACKUP: &str =
|
||||||
|
"RWQEeUSLlX2p4EPUPN1GjeUv34qA0p2B6wJYzcou5oOwAkkw0f+r9FmZ";
|
||||||
|
|
||||||
/// Repräsentiert ein Asset in einem Gitea-Release
|
/// Repräsentiert ein Asset in einem Gitea-Release
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -64,18 +66,84 @@ impl Default for UpgradeOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
|
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (U-01).
|
||||||
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<()> {
|
///
|
||||||
|
/// Erzwingt strikt Pre-Hashing (`allow_legacy = false`), um Malleability- und Längenangriffe auszuschließen.
|
||||||
|
/// Gibt bei erfolgreicher Verifikation den kryptografisch authentifizierten `trusted comment` zurück.
|
||||||
|
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<String> {
|
||||||
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
||||||
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
||||||
let signature = minisign_verify::Signature::decode(sig_str)
|
let signature = minisign_verify::Signature::decode(sig_str)
|
||||||
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
||||||
pubkey
|
pubkey
|
||||||
.verify(data, &signature, true)
|
.verify(data, &signature, false)
|
||||||
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
|
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
|
||||||
|
Ok(signature.trusted_comment().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrahiert die Versionsnummer aus dem Trusted Comment einer Minisign-Signatur und
|
||||||
|
/// validiert sie gegen das erwartete Release-Tag (U-02).
|
||||||
|
pub fn validate_trusted_comment_version(
|
||||||
|
trusted_comment: &str,
|
||||||
|
expected_release: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let clean_expected = expected_release.trim().trim_start_matches(['v', 'V']);
|
||||||
|
|
||||||
|
if clean_expected.is_empty() {
|
||||||
|
bail!("Erwartetes Release-Tag darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durchsuche die Tokens des Trusted Comments (getrennt durch Tabs oder Leerzeichen)
|
||||||
|
let found = trusted_comment.split_whitespace().any(|token| {
|
||||||
|
let val = if let Some(stripped) = token.strip_prefix("version:") {
|
||||||
|
stripped
|
||||||
|
} else if let Some(stripped) = token.strip_prefix("release:") {
|
||||||
|
stripped
|
||||||
|
} else if let Some(stripped) = token.strip_prefix("tag:") {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
token
|
||||||
|
};
|
||||||
|
|
||||||
|
let clean_val = val.trim_start_matches(['v', 'V']);
|
||||||
|
clean_val == clean_expected
|
||||||
|
});
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
bail!(
|
||||||
|
"Signatur-Validierungsfehler (U-02): Der kryptografisch gesicherte Trusted Comment \
|
||||||
|
'{}' stimmt nicht mit der erwarteten Release-Version '{}' überein! \
|
||||||
|
Möglicher Replay-Angriff mit einer älteren signierten Prüfsummendatei.",
|
||||||
|
trusted_comment.trim(),
|
||||||
|
expected_release
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Überprüft eine Minisign-Signatur für ein Release-Manifest.
|
||||||
|
///
|
||||||
|
/// Prüft primär gegen `SANCTUM_RELEASE_PUBKEY`. Falls diese Verifikation fehlschlägt,
|
||||||
|
/// wird transparent gegen den sekundären Backup-Schlüssel `SANCTUM_RELEASE_PUBKEY_BACKUP`
|
||||||
|
/// geprüft (U-03), um unterbrechungsfreie Schlüsselrotationen zu ermöglichen.
|
||||||
|
pub fn verify_release_manifest_signature(data: &[u8], sig_str: &str) -> Result<String> {
|
||||||
|
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY) {
|
||||||
|
Ok(comment) => Ok(comment),
|
||||||
|
Err(primary_err) => {
|
||||||
|
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY_BACKUP) {
|
||||||
|
Ok(comment) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Minisign-Signatur erfolgreich mit sekundärem Backup-Schlüssel verifiziert."
|
||||||
|
);
|
||||||
|
Ok(comment)
|
||||||
|
}
|
||||||
|
Err(_) => Err(primary_err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Erkannter Paketmanager
|
/// Erkannter Paketmanager
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PackageManager {
|
pub enum PackageManager {
|
||||||
@@ -189,7 +257,7 @@ pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) ->
|
|||||||
|
|
||||||
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
|
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
|
||||||
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
|
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
|
||||||
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
|
let clean = ver_str.trim().trim_start_matches(['v', 'V']);
|
||||||
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
|
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,10 +567,11 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
|
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
|
||||||
ui::dim("[-]")
|
ui::dim("[-]")
|
||||||
);
|
);
|
||||||
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
|
let trusted_comment = verify_release_manifest_signature(sums_content.as_bytes(), &sig_content)
|
||||||
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
|
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
|
||||||
|
validate_trusted_comment_version(&trusted_comment, remote_tag)?;
|
||||||
println!(
|
println!(
|
||||||
" • Signatur: {} (Minisign Ed25519)",
|
" • Signatur: {} (Minisign Ed25519, Version validiert)",
|
||||||
ui::green("Gültig")
|
ui::green("Gültig")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+37
-8
@@ -4,7 +4,7 @@ use std::path::Path;
|
|||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
|
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3};
|
||||||
use crate::storage::Database;
|
use crate::storage::Database;
|
||||||
|
|
||||||
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
|
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
|
||||||
@@ -81,7 +81,10 @@ pub fn verify_container(
|
|||||||
let meta = match db.read_meta() {
|
let meta = match db.read_meta() {
|
||||||
Ok(m) => {
|
Ok(m) => {
|
||||||
report.format_version = m.version;
|
report.format_version = m.version;
|
||||||
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
|
if m.version != FORMAT_VERSION_V1
|
||||||
|
&& m.version != FORMAT_VERSION_V2
|
||||||
|
&& m.version != FORMAT_VERSION_V3
|
||||||
|
{
|
||||||
report.header_ok = false;
|
report.header_ok = false;
|
||||||
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
|
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
|
||||||
}
|
}
|
||||||
@@ -247,6 +250,27 @@ pub fn verify_container(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 3b. Metadaten-Authentifizierung (HMAC-SHA256) für Format V3 (K-01)
|
||||||
|
if let Some(active_dek) = dek {
|
||||||
|
if let Some(ref m) = meta {
|
||||||
|
if m.version >= FORMAT_VERSION_V3 {
|
||||||
|
match db.verify_metadata_mac(active_dek) {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
report.errors.push(
|
||||||
|
"Kritischer Metadaten-Authentifizierungsfehler (K-01): Metadaten-MAC ungültig. Manipulationsversuch an Dateinamen, Größen oder Verzeichnisstruktur in der SQLite-Datenbank erkannt.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.push(format!("Fehler bei der Metadaten-MAC-Prüfung: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
|
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
|
||||||
let chunk_headers = db
|
let chunk_headers = db
|
||||||
.list_all_chunk_headers()
|
.list_all_chunk_headers()
|
||||||
@@ -277,6 +301,7 @@ pub fn verify_container(
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
format_version,
|
format_version,
|
||||||
|
record.generation,
|
||||||
) {
|
) {
|
||||||
Ok(plaintext) => {
|
Ok(plaintext) => {
|
||||||
report.total_bytes_decrypted += plaintext.len() as u64;
|
report.total_bytes_decrypted += plaintext.len() as u64;
|
||||||
@@ -339,6 +364,7 @@ mod tests {
|
|||||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).unwrap();
|
let db = Database::open(&container_path).unwrap();
|
||||||
|
db.set_active_dek(dek.clone());
|
||||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -348,12 +374,14 @@ mod tests {
|
|||||||
|
|
||||||
// 2 Chunks schreiben
|
// 2 Chunks schreiben
|
||||||
let chunk0_data = b"Sample JPEG data header and pixels";
|
let chunk0_data = b"Sample JPEG data header and pixels";
|
||||||
let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap();
|
let (ct0, n0, t0) =
|
||||||
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
|
encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION, 0).unwrap();
|
||||||
|
db.write_chunk(file.id, 0, 0, &n0, &t0, &ct0).unwrap();
|
||||||
|
|
||||||
let chunk1_data = b"Additional payload data bytes";
|
let chunk1_data = b"Additional payload data bytes";
|
||||||
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
|
let (ct1, n1, t1) =
|
||||||
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
|
encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION, 0).unwrap();
|
||||||
|
db.write_chunk(file.id, 1, 0, &n1, &t1, &ct1).unwrap();
|
||||||
|
|
||||||
db.update_node_size_and_time(
|
db.update_node_size_and_time(
|
||||||
file.id,
|
file.id,
|
||||||
@@ -401,13 +429,14 @@ mod tests {
|
|||||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).unwrap();
|
let db = Database::open(&container_path).unwrap();
|
||||||
|
db.set_active_dek(dek.clone());
|
||||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let file = db.create_node(1, "document.pdf", false).unwrap();
|
let file = db.create_node(1, "document.pdf", false).unwrap();
|
||||||
let chunk_data = b"Vital documents that must not be corrupted";
|
let chunk_data = b"Vital documents that must not be corrupted";
|
||||||
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
|
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap();
|
||||||
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
|
db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
|
||||||
db.checkpoint().unwrap();
|
db.checkpoint().unwrap();
|
||||||
drop(db);
|
drop(db);
|
||||||
|
|
||||||
|
|||||||
+652
-65
@@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use bytes::{Buf, Bytes, BytesMut};
|
use bytes::{Buf, Bytes, BytesMut};
|
||||||
use dav_server::{
|
use dav_server::{
|
||||||
davpath::DavPath,
|
davpath::DavPath,
|
||||||
@@ -13,6 +14,7 @@ use dav_server::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
|
use std::path::Path;
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
@@ -20,23 +22,128 @@ use crate::carrier::CarrierFs;
|
|||||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||||
use crate::storage::{Database, NodeRecord};
|
use crate::storage::{Database, NodeRecord};
|
||||||
|
|
||||||
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache-
|
/// Exakte Namen von Explorer-, OS- und Desktop-Metadaten (V-04).
|
||||||
/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig
|
pub const LEAK_EXACT_NAMES: &[&str] = &[
|
||||||
/// im Container blockiert und verborgen werden (Anti-Leak Shield).
|
"thumbs.db",
|
||||||
|
"ehthumbs.db",
|
||||||
|
"ehthumbs_vista.db",
|
||||||
|
"desktop.ini",
|
||||||
|
"folder.jpg",
|
||||||
|
"albumartsmall.jpg",
|
||||||
|
"autorun.inf",
|
||||||
|
".ds_store",
|
||||||
|
".directory",
|
||||||
|
".fseventsd",
|
||||||
|
".spotlight-v100",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Präfixe bekannter temporärer Metadaten und Lock-Dateien (V-04).
|
||||||
|
pub const LEAK_PREFIXES: &[&str] = &[
|
||||||
|
"~$", // MS Office temporäre Lock-Dateien (z. B. ~$Document.docx)
|
||||||
|
"._", // macOS AppleDouble Metadaten-Dateien (z. B. ._Document.pdf)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Suffixe und Dateiendungen temporärer Caches und unvollständiger Downloads (V-04).
|
||||||
|
pub const LEAK_SUFFIXES: &[&str] = &[
|
||||||
|
".tmp",
|
||||||
|
".temp",
|
||||||
|
".crdownload", // Google Chrome temporärer Download
|
||||||
|
".part", // Mozilla Firefox unvollständiger Download
|
||||||
|
".partial",
|
||||||
|
"~", // Linux/UNIX Editor-Backups (Vim, Emacs, Gedit)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer-, OS- oder Anwendungs-
|
||||||
|
/// Metadaten-, Cache- oder Lock-Dateien gehört, die standardmäßig im Container
|
||||||
|
/// blockiert und verborgen werden (Anti-Leak Shield, V-04).
|
||||||
pub fn is_leak_file(filename: &str) -> bool {
|
pub fn is_leak_file(filename: &str) -> bool {
|
||||||
let lower = filename.trim().to_ascii_lowercase();
|
is_leak_file_with_custom(filename, &[])
|
||||||
match lower.as_str() {
|
}
|
||||||
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
|
|
||||||
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
|
/// Prüft, ob ein Dateiname gemäß der Standardregeln oder benutzerdefinierten Regeln (V-04)
|
||||||
_ => {
|
/// als Leak-Datei blockiert werden soll.
|
||||||
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
|
pub fn is_leak_file_with_custom(filename: &str, custom_rules: &[String]) -> bool {
|
||||||
{
|
let trimmed = filename.trim();
|
||||||
true
|
if trimmed.is_empty() {
|
||||||
} else {
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. NTFS Alternate Data Streams (ADS) wie "file.txt:Zone.Identifier"
|
||||||
|
if trimmed.contains(':') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// 2. Exakte Namen
|
||||||
|
for &exact in LEAK_EXACT_NAMES {
|
||||||
|
if lower == exact {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Präfixe
|
||||||
|
for &prefix in LEAK_PREFIXES {
|
||||||
|
if lower.starts_with(prefix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Suffixe
|
||||||
|
for &suffix in LEAK_SUFFIXES {
|
||||||
|
if lower.ends_with(suffix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Spezielle Muster
|
||||||
|
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if lower.starts_with(".trash") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Benutzerdefinierte Regeln
|
||||||
|
for custom in custom_rules {
|
||||||
|
let pat = custom.trim().to_ascii_lowercase();
|
||||||
|
if pat.is_empty() || pat.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pat.starts_with('*') && pat.ends_with('*') && pat.len() > 2 {
|
||||||
|
let sub = &pat[1..pat.len() - 1];
|
||||||
|
if lower.contains(sub) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if pat.starts_with('*') {
|
||||||
|
if lower.ends_with(&pat[1..]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if pat.ends_with('*') {
|
||||||
|
if lower.starts_with(&pat[..pat.len() - 1]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if lower == pat {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt eine benutzerdefinierte Liste von Anti-Leak Filterregeln aus einer Textdatei (V-04).
|
||||||
|
pub fn load_anti_leak_list<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||||
|
let p = path.as_ref();
|
||||||
|
let content = std::fs::read_to_string(p)
|
||||||
|
.with_context(|| format!("Konnte Anti-Leak-Listendatei '{}' nicht lesen", p.display()))?;
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() && !trimmed.starts_with('#') {
|
||||||
|
rules.push(trimmed.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -94,6 +201,42 @@ impl DavDirEntry for SanctumDirEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RAII-Guard für im physischen RAM verriegelte Schlüssel (VirtualLock / mlock).
|
||||||
|
/// Entriegelt den Speicherbereich via VirtualUnlock / munlock erst beim Drop der letzten verbleibenden Referenz (V-03).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MemoryLockGuard(Zeroizing<[u8; 32]>);
|
||||||
|
|
||||||
|
impl MemoryLockGuard {
|
||||||
|
pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
|
||||||
|
crate::windows::lock_memory(key.as_ptr(), 32);
|
||||||
|
Self(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> &Zeroizing<[u8; 32]> {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for MemoryLockGuard {
|
||||||
|
type Target = Zeroizing<[u8; 32]>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8; 32]> for MemoryLockGuard {
|
||||||
|
fn as_ref(&self) -> &[u8; 32] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MemoryLockGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
crate::windows::unlock_memory(self.0.as_ptr(), 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Datei-Handle mit Streaming & Chunk-Pufferung
|
// Datei-Handle mit Streaming & Chunk-Pufferung
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -103,7 +246,7 @@ pub struct SanctumFile {
|
|||||||
file_size: u64,
|
file_size: u64,
|
||||||
cursor: u64,
|
cursor: u64,
|
||||||
db: Database,
|
db: Database,
|
||||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
dek: Arc<MemoryLockGuard>,
|
||||||
meta: SanctumMetaData,
|
meta: SanctumMetaData,
|
||||||
// (chunk_index, decrypted_payload, is_dirty)
|
// (chunk_index, decrypted_payload, is_dirty)
|
||||||
cached_chunk: Option<(u32, Vec<u8>, bool)>,
|
cached_chunk: Option<(u32, Vec<u8>, bool)>,
|
||||||
@@ -126,7 +269,7 @@ impl SanctumFile {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
node: NodeRecord,
|
node: NodeRecord,
|
||||||
db: Database,
|
db: Database,
|
||||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
dek: Arc<MemoryLockGuard>,
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
last_activity: Arc<AtomicU64>,
|
last_activity: Arc<AtomicU64>,
|
||||||
append: bool,
|
append: bool,
|
||||||
@@ -171,17 +314,21 @@ impl SanctumFile {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
if let Some((idx, ref data, true)) = self.cached_chunk {
|
if let Some((idx, ref data, true)) = self.cached_chunk {
|
||||||
|
let gen = self
|
||||||
|
.db
|
||||||
|
.next_chunk_generation(self.node_id, idx)
|
||||||
|
.unwrap_or(0);
|
||||||
let (ciphertext, nonce, tag) =
|
let (ciphertext, nonce, tag) =
|
||||||
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(
|
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version, gen)
|
||||||
|e| {
|
.map_err(|e| {
|
||||||
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
||||||
FsError::GeneralFailure
|
FsError::GeneralFailure
|
||||||
},
|
})?;
|
||||||
)?;
|
|
||||||
|
|
||||||
if let Err(e) = self.db.write_chunk_and_update_size(
|
if let Err(e) = self.db.write_chunk_and_update_size(
|
||||||
self.node_id,
|
self.node_id,
|
||||||
idx,
|
idx,
|
||||||
|
gen,
|
||||||
&nonce,
|
&nonce,
|
||||||
&tag,
|
&tag,
|
||||||
&ciphertext,
|
&ciphertext,
|
||||||
@@ -228,6 +375,12 @@ impl SanctumFile {
|
|||||||
data.zeroize();
|
data.zeroize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let expected_chunks = if self.file_size == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.file_size.div_ceil(CHUNK_SIZE as u64) as u32
|
||||||
|
};
|
||||||
|
|
||||||
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
|
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
|
||||||
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
|
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
|
||||||
FsError::GeneralFailure
|
FsError::GeneralFailure
|
||||||
@@ -240,12 +393,22 @@ impl SanctumFile {
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
self.format_version,
|
self.format_version,
|
||||||
|
record.generation,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
||||||
FsError::GeneralFailure
|
FsError::GeneralFailure
|
||||||
})?,
|
})?,
|
||||||
None => Vec::new(),
|
None => {
|
||||||
|
if chunk_index < expected_chunks {
|
||||||
|
error!(
|
||||||
|
"Integritätsfehler: Fehlender Chunk #{} bei Knoten id={} (erwartete Chunks: {})",
|
||||||
|
chunk_index, self.node_id, expected_chunks
|
||||||
|
);
|
||||||
|
return Err(FsError::GeneralFailure);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.cached_chunk = Some((chunk_index, payload, false));
|
self.cached_chunk = Some((chunk_index, payload, false));
|
||||||
@@ -261,9 +424,9 @@ impl SanctumFile {
|
|||||||
impl Drop for SanctumFile {
|
impl Drop for SanctumFile {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Err(e) = self.flush_cached_chunk_and_size() {
|
if let Err(e) = self.flush_cached_chunk_and_size() {
|
||||||
warn!(
|
error!(
|
||||||
"Fehler beim automatischen Flush im SanctumFile::drop: {:?}",
|
"SanctumFile::drop: Fehler beim automatischen Flush von Knoten {}: {:?}",
|
||||||
e
|
self.node_id, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||||
@@ -304,6 +467,13 @@ impl DavFile for SanctumFile {
|
|||||||
|
|
||||||
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
|
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
|
||||||
if offset_in_chunk >= chunk_data.len() {
|
if offset_in_chunk >= chunk_data.len() {
|
||||||
|
if self.cursor < self.file_size {
|
||||||
|
error!(
|
||||||
|
"Integritätsfehler: Unerwartetes Chunk-Ende bei Offset {} in Chunk #{} (Dateigröße: {}, Cursor: {})",
|
||||||
|
offset_in_chunk, chunk_idx, self.file_size, self.cursor
|
||||||
|
);
|
||||||
|
return Err(FsError::GeneralFailure);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +484,13 @@ impl DavFile for SanctumFile {
|
|||||||
count -= available;
|
count -= available;
|
||||||
|
|
||||||
if available < to_read {
|
if available < to_read {
|
||||||
|
if self.cursor < self.file_size {
|
||||||
|
error!(
|
||||||
|
"Integritätsfehler: Vorzeitiges Chunk-Ende (verfügbar: {}, benötigt: {}, Dateigröße: {}, Cursor: {})",
|
||||||
|
available, to_read, self.file_size, self.cursor
|
||||||
|
);
|
||||||
|
return Err(FsError::GeneralFailure);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +502,34 @@ impl DavFile for SanctumFile {
|
|||||||
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
||||||
self.touch();
|
self.touch();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
// V-06: Sparse Writes — Lücke zwischen bisherigem Dateiende und Cursor mit Nullen füllen
|
||||||
|
if self.cursor > self.file_size {
|
||||||
|
let target = self.cursor;
|
||||||
|
while self.file_size < target {
|
||||||
|
let chunk_idx = (self.file_size / CHUNK_SIZE as u64) as u32;
|
||||||
|
let offset_in_chunk = (self.file_size % CHUNK_SIZE as u64) as usize;
|
||||||
|
let space_in_chunk = CHUNK_SIZE - offset_in_chunk;
|
||||||
|
let to_pad = ((target - self.file_size) as usize).min(space_in_chunk);
|
||||||
|
|
||||||
|
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
|
||||||
|
if chunk_data.len() < offset_in_chunk + to_pad {
|
||||||
|
chunk_data.resize(offset_in_chunk + to_pad, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut is_full = false;
|
||||||
|
if let Some((_, ref d, ref mut dirty)) = self.cached_chunk {
|
||||||
|
*dirty = true;
|
||||||
|
is_full = d.len() >= CHUNK_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file_size += to_pad as u64;
|
||||||
|
|
||||||
|
if is_full {
|
||||||
|
self.flush_cached_chunk_and_size()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut src = &buf[..];
|
let mut src = &buf[..];
|
||||||
|
|
||||||
while !src.is_empty() {
|
while !src.is_empty() {
|
||||||
@@ -410,13 +615,15 @@ impl DavFile for SanctumFile {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SanctumFs {
|
pub struct SanctumFs {
|
||||||
db: Database,
|
db: Database,
|
||||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
dek: Arc<MemoryLockGuard>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
|
carrier_dek: Option<Arc<MemoryLockGuard>>,
|
||||||
carrier_node_id: Option<i64>,
|
carrier_node_id: Option<i64>,
|
||||||
carrier_fs: Option<CarrierFs>,
|
carrier_fs: Option<CarrierFs>,
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
|
custom_leak_rules: Arc<Vec<String>>,
|
||||||
|
leak_counter: Arc<AtomicU64>,
|
||||||
vault_id: u32,
|
vault_id: u32,
|
||||||
last_activity: Arc<AtomicU64>,
|
last_activity: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
@@ -435,6 +642,25 @@ impl SanctumFs {
|
|||||||
Self::with_vault(db, dek, format_version, anti_leak, 0)
|
Self::with_vault(db, dek, format_version, anti_leak, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_options_and_leak_rules(
|
||||||
|
db: Database,
|
||||||
|
dek: Zeroizing<[u8; 32]>,
|
||||||
|
format_version: u32,
|
||||||
|
anti_leak: bool,
|
||||||
|
custom_leak_rules: Vec<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_carrier_and_leak_rules(
|
||||||
|
db,
|
||||||
|
dek,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
format_version,
|
||||||
|
anti_leak,
|
||||||
|
custom_leak_rules,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_vault(
|
pub fn with_vault(
|
||||||
db: Database,
|
db: Database,
|
||||||
dek: Zeroizing<[u8; 32]>,
|
dek: Zeroizing<[u8; 32]>,
|
||||||
@@ -453,13 +679,37 @@ impl SanctumFs {
|
|||||||
format_version: u32,
|
format_version: u32,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
vault_id: u32,
|
vault_id: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_carrier_and_leak_rules(
|
||||||
|
db,
|
||||||
|
dek,
|
||||||
|
carrier_dek,
|
||||||
|
carrier_node_id,
|
||||||
|
format_version,
|
||||||
|
anti_leak,
|
||||||
|
Vec::new(),
|
||||||
|
vault_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_carrier_and_leak_rules(
|
||||||
|
db: Database,
|
||||||
|
dek: Zeroizing<[u8; 32]>,
|
||||||
|
carrier_dek: Option<Zeroizing<[u8; 32]>>,
|
||||||
|
carrier_node_id: Option<i64>,
|
||||||
|
format_version: u32,
|
||||||
|
anti_leak: bool,
|
||||||
|
custom_leak_rules: Vec<String>,
|
||||||
|
vault_id: u32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let dek_arc = Arc::new(dek);
|
let dek_guard = Arc::new(MemoryLockGuard::new(dek));
|
||||||
let carrier_dek_arc = carrier_dek.map(Arc::new);
|
let carrier_dek_guard = carrier_dek.map(|k| Arc::new(MemoryLockGuard::new(k)));
|
||||||
|
|
||||||
|
let db = db.with_session(vault_id, (*dek_guard.key()).clone());
|
||||||
|
|
||||||
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
|
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
|
||||||
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
|
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
|
||||||
@@ -472,14 +722,15 @@ impl SanctumFs {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let carrier_fs = if vault_id == 1 {
|
let carrier_fs = if vault_id == 1 {
|
||||||
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
|
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_guard, carrier_node_id) {
|
||||||
match CarrierFs::load(
|
match CarrierFs::load_with_leak_rules(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
c_nid,
|
c_nid,
|
||||||
c_dek.clone(),
|
Arc::new((*c_dek.key()).clone()),
|
||||||
dek_arc.clone(),
|
Arc::new((*dek_guard.key()).clone()),
|
||||||
format_version,
|
format_version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules.clone(),
|
||||||
) {
|
) {
|
||||||
Ok(cfs) => Some(cfs),
|
Ok(cfs) => Some(cfs),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -494,25 +745,25 @@ impl SanctumFs {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Forensischer RAM-Paging-Schutz via VirtualLock (verhindert Auslagerung in pagefile.sys)
|
|
||||||
crate::windows::lock_memory(dek_arc.as_ptr(), 32);
|
|
||||||
if let Some(ref c_dek) = carrier_dek_arc {
|
|
||||||
crate::windows::lock_memory(c_dek.as_ptr(), 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
dek: dek_arc,
|
dek: dek_guard,
|
||||||
carrier_dek: carrier_dek_arc,
|
carrier_dek: carrier_dek_guard,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
carrier_fs,
|
carrier_fs,
|
||||||
format_version,
|
format_version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules: Arc::new(custom_leak_rules),
|
||||||
|
leak_counter: Arc::new(AtomicU64::new(0)),
|
||||||
vault_id,
|
vault_id,
|
||||||
last_activity: Arc::new(AtomicU64::new(now)),
|
last_activity: Arc::new(AtomicU64::new(now)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn dek_strong_count(&self) -> usize {
|
||||||
|
Arc::strong_count(&self.dek)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn vault_id(&self) -> u32 {
|
pub fn vault_id(&self) -> u32 {
|
||||||
self.vault_id
|
self.vault_id
|
||||||
}
|
}
|
||||||
@@ -529,6 +780,22 @@ impl SanctumFs {
|
|||||||
self.anti_leak
|
self.anti_leak
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn leak_counter(&self) -> Arc<AtomicU64> {
|
||||||
|
if let Some(ref cfs) = self.carrier_fs {
|
||||||
|
cfs.leak_counter()
|
||||||
|
} else {
|
||||||
|
self.leak_counter.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leak_count(&self) -> u64 {
|
||||||
|
self.leak_counter().load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_leak(&self, name: &str) -> bool {
|
||||||
|
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn touch(&self) {
|
pub fn touch(&self) {
|
||||||
if let Some(ref cfs) = self.carrier_fs {
|
if let Some(ref cfs) = self.carrier_fs {
|
||||||
cfs.touch();
|
cfs.touch();
|
||||||
@@ -592,22 +859,13 @@ pub fn validate_path_safety(path: &str) -> Result<(), FsError> {
|
|||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
for c in path.chars() {
|
for c in path.chars() {
|
||||||
if (c as u32) < 0x20 && c != '\t' {
|
if (c as u32) < 0x20 {
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SanctumFs {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
crate::windows::unlock_memory(self.dek.as_ptr(), 32);
|
|
||||||
if let Some(ref c_dek) = self.carrier_dek {
|
|
||||||
crate::windows::unlock_memory(c_dek.as_ptr(), 32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DavFileSystem for SanctumFs {
|
impl DavFileSystem for SanctumFs {
|
||||||
fn open<'a>(
|
fn open<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -623,13 +881,14 @@ impl DavFileSystem for SanctumFs {
|
|||||||
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
|
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
|
||||||
|
|
||||||
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
|
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
|
||||||
if self.anti_leak && is_leak_file(file_name) {
|
if self.is_leak(file_name) {
|
||||||
if options.create
|
if options.create
|
||||||
|| options.create_new
|
|| options.create_new
|
||||||
|| options.write
|
|| options.write
|
||||||
|| options.append
|
|| options.append
|
||||||
|| options.truncate
|
|| options.truncate
|
||||||
{
|
{
|
||||||
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
debug!(
|
debug!(
|
||||||
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
|
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
|
||||||
file_name
|
file_name
|
||||||
@@ -731,7 +990,14 @@ impl DavFileSystem for SanctumFs {
|
|||||||
|
|
||||||
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
|
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
|
.filter(|child| {
|
||||||
|
if self.is_leak(&child.name) {
|
||||||
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(|child| {
|
.map(|child| {
|
||||||
Ok(Box::new(SanctumDirEntry {
|
Ok(Box::new(SanctumDirEntry {
|
||||||
name: child.name,
|
name: child.name,
|
||||||
@@ -786,7 +1052,8 @@ impl DavFileSystem for SanctumFs {
|
|||||||
let path_str = Self::path_to_str(path);
|
let path_str = Self::path_to_str(path);
|
||||||
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
|
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
|
||||||
|
|
||||||
if self.anti_leak && is_leak_file(dir_name) {
|
if self.is_leak(dir_name) {
|
||||||
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,7 +1157,8 @@ impl DavFileSystem for SanctumFs {
|
|||||||
|
|
||||||
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
||||||
|
|
||||||
if self.anti_leak && is_leak_file(to_name) {
|
if self.is_leak(to_name) {
|
||||||
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,11 +1191,19 @@ impl DavFileSystem for SanctumFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||||
|
if let Some(ref cfs) = self.carrier_fs {
|
||||||
|
return cfs.copy(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
self.touch();
|
self.touch();
|
||||||
let from_str = Self::path_to_str(from);
|
let from_str = Self::path_to_str(from);
|
||||||
let to_str = Self::path_to_str(to);
|
let to_str = Self::path_to_str(to);
|
||||||
|
|
||||||
|
if from_str == to_str {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
||||||
|
|
||||||
if node.is_dir {
|
if node.is_dir {
|
||||||
@@ -941,21 +1217,35 @@ impl DavFileSystem for SanctumFs {
|
|||||||
|
|
||||||
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
||||||
|
|
||||||
if self.anti_leak && is_leak_file(to_name) {
|
if self.is_leak(to_name) {
|
||||||
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
|
|
||||||
if let Some(dest) = self.resolve_path(&to_str)? {
|
|
||||||
if self.carrier_node_id == Some(dest.id) {
|
|
||||||
return Err(FsError::Forbidden);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let to_parent = self
|
let to_parent = self
|
||||||
.resolve_path(to_parent_path)?
|
.resolve_path(to_parent_path)?
|
||||||
.ok_or(FsError::NotFound)?;
|
.ok_or(FsError::NotFound)?;
|
||||||
|
|
||||||
|
if !to_parent.is_dir {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC-4918 (V-07): Falls Zieldatei bereits existiert:
|
||||||
|
// - Wenn Verzeichnis: 403 Forbidden
|
||||||
|
// - Wenn Datei: bestehende Datei vor dem Kopieren löschen & shredden
|
||||||
|
if let Some(dest) = self.resolve_path(&to_str)? {
|
||||||
|
if dest.is_dir {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
|
||||||
|
if self.carrier_node_id == Some(dest.id) {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
self.db
|
||||||
|
.delete_node(dest.id)
|
||||||
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
|
}
|
||||||
|
|
||||||
let dest_node = self.create_node(to_parent.id, to_name, false)?;
|
let dest_node = self.create_node(to_parent.id, to_name, false)?;
|
||||||
|
|
||||||
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
|
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
|
||||||
@@ -979,20 +1269,26 @@ impl DavFileSystem for SanctumFs {
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
self.format_version,
|
self.format_version,
|
||||||
|
record.generation,
|
||||||
)
|
)
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
|
|
||||||
|
let gen = self
|
||||||
|
.db
|
||||||
|
.next_chunk_generation(dest_node.id, idx)
|
||||||
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
|
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
|
||||||
&self.dek,
|
&self.dek,
|
||||||
dest_node.id,
|
dest_node.id,
|
||||||
idx,
|
idx,
|
||||||
&plaintext,
|
&plaintext,
|
||||||
self.format_version,
|
self.format_version,
|
||||||
|
gen,
|
||||||
)
|
)
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct)
|
.write_chunk(dest_node.id, idx, gen, &new_nonce, &new_tag, &new_ct)
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,10 +1306,28 @@ impl DavFileSystem for SanctumFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_quota(&self) -> FsFuture<'_, (u64, Option<u64>)> {
|
fn get_quota(&self) -> FsFuture<'_, (u64, Option<u64>)> {
|
||||||
|
if let Some(ref cfs) = self.carrier_fs {
|
||||||
|
return cfs.get_quota();
|
||||||
|
}
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// Virtueller Speicherplatz für Explorer: 1 TB
|
self.touch();
|
||||||
let total_capacity: u64 = 1024 * 1024 * 1024 * 1024;
|
// Z-04: Echte Containergröße (physisch auf Disk) und freier Host-Speicher
|
||||||
Ok((0, Some(total_capacity)))
|
let container_size = self.db.get_container_file_size().unwrap_or(0);
|
||||||
|
let used_bytes = if container_size > 0 {
|
||||||
|
container_size
|
||||||
|
} else {
|
||||||
|
self.db.get_total_used_size().unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let free_host_space = self
|
||||||
|
.db
|
||||||
|
.container_path()
|
||||||
|
.and_then(|p| crate::windows::get_available_disk_space(&p))
|
||||||
|
.unwrap_or(1024 * 1024 * 1024 * 1024); // Fallback: 1 TB
|
||||||
|
|
||||||
|
let total_capacity = used_bytes.saturating_add(free_host_space);
|
||||||
|
Ok((used_bytes, Some(total_capacity)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1343,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_leak_file() {
|
fn test_is_leak_file() {
|
||||||
|
// Exakte Namen
|
||||||
assert!(is_leak_file("Thumbs.db"));
|
assert!(is_leak_file("Thumbs.db"));
|
||||||
assert!(is_leak_file("thumbs.db"));
|
assert!(is_leak_file("thumbs.db"));
|
||||||
assert!(is_leak_file("THUMBS.DB"));
|
assert!(is_leak_file("THUMBS.DB"));
|
||||||
@@ -1042,12 +1357,78 @@ mod tests {
|
|||||||
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
|
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
|
||||||
assert!(is_leak_file("autorun.inf"));
|
assert!(is_leak_file("autorun.inf"));
|
||||||
assert!(is_leak_file(".ds_store"));
|
assert!(is_leak_file(".ds_store"));
|
||||||
|
assert!(is_leak_file(".DS_Store"));
|
||||||
|
assert!(is_leak_file(".directory"));
|
||||||
|
assert!(is_leak_file(".fseventsd"));
|
||||||
|
assert!(is_leak_file(".spotlight-v100"));
|
||||||
|
|
||||||
|
// Präfixe (V-04)
|
||||||
|
assert!(is_leak_file("~$MyDocument.docx"));
|
||||||
|
assert!(is_leak_file("._Document.pdf"));
|
||||||
|
|
||||||
|
// Suffixe (V-04)
|
||||||
|
assert!(is_leak_file("temp_file.tmp"));
|
||||||
|
assert!(is_leak_file("cache.temp"));
|
||||||
|
assert!(is_leak_file("video.crdownload"));
|
||||||
|
assert!(is_leak_file("archive.tar.gz.part"));
|
||||||
|
assert!(is_leak_file("bigfile.partial"));
|
||||||
|
assert!(is_leak_file("notes.txt~"));
|
||||||
|
|
||||||
|
// NTFS Alternate Data Streams (ADS, V-04)
|
||||||
|
assert!(is_leak_file("document.pdf:Zone.Identifier"));
|
||||||
|
assert!(is_leak_file("file.exe:$DATA"));
|
||||||
|
|
||||||
|
// Wildcards (.trash*, V-04)
|
||||||
|
assert!(is_leak_file(".trash"));
|
||||||
|
assert!(is_leak_file(".Trash-1000"));
|
||||||
|
assert!(is_leak_file(".trashes"));
|
||||||
|
|
||||||
// Harmlos:
|
// Harmlos:
|
||||||
assert!(!is_leak_file("secret.txt"));
|
assert!(!is_leak_file("secret.txt"));
|
||||||
assert!(!is_leak_file("passwords.kdbx"));
|
assert!(!is_leak_file("passwords.kdbx"));
|
||||||
assert!(!is_leak_file("my_folder.jpg.txt"));
|
assert!(!is_leak_file("my_folder.jpg.txt"));
|
||||||
assert!(!is_leak_file("desktop_notes.ini.bak"));
|
assert!(!is_leak_file("desktop_notes.ini.bak"));
|
||||||
|
assert!(!is_leak_file("temp_report.docx"));
|
||||||
|
assert!(!is_leak_file("part1_chapter.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v04_custom_anti_leak_rules_and_loader() {
|
||||||
|
let custom_rules = vec![
|
||||||
|
"*.secret_log".to_string(),
|
||||||
|
"debug_*".to_string(),
|
||||||
|
"*_temp_*".to_string(),
|
||||||
|
"custom_exact.bin".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(is_leak_file_with_custom("audit.secret_log", &custom_rules));
|
||||||
|
assert!(is_leak_file_with_custom("debug_dump.txt", &custom_rules));
|
||||||
|
assert!(is_leak_file_with_custom(
|
||||||
|
"app_temp_cache.dat",
|
||||||
|
&custom_rules
|
||||||
|
));
|
||||||
|
assert!(is_leak_file_with_custom("custom_exact.bin", &custom_rules));
|
||||||
|
|
||||||
|
// Normale Datei wird nicht blockiert
|
||||||
|
assert!(!is_leak_file_with_custom("regular_file.txt", &custom_rules));
|
||||||
|
|
||||||
|
// Test load_anti_leak_list
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let rule_file = temp_dir.path().join("anti_leak_rules.txt");
|
||||||
|
std::fs::write(
|
||||||
|
&rule_file,
|
||||||
|
"# Kommentarzeile\n*.bak\n\n # Noch ein Kommentar\nprivate_*\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = load_anti_leak_list(&rule_file).unwrap();
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert_eq!(loaded[0], "*.bak");
|
||||||
|
assert_eq!(loaded[1], "private_*");
|
||||||
|
|
||||||
|
assert!(is_leak_file_with_custom("data.bak", &loaded));
|
||||||
|
assert!(is_leak_file_with_custom("private_keys.pem", &loaded));
|
||||||
|
assert!(!is_leak_file_with_custom("public_data.txt", &loaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {
|
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {
|
||||||
@@ -1281,6 +1662,7 @@ mod tests {
|
|||||||
assert!(validate_path_safety("/bad\x01file.txt").is_err());
|
assert!(validate_path_safety("/bad\x01file.txt").is_err());
|
||||||
assert!(validate_path_safety("/bad\rfile.txt").is_err());
|
assert!(validate_path_safety("/bad\rfile.txt").is_err());
|
||||||
assert!(validate_path_safety("/bad\nfile.txt").is_err());
|
assert!(validate_path_safety("/bad\nfile.txt").is_err());
|
||||||
|
assert!(validate_path_safety("/bad\tfile.txt").is_err());
|
||||||
|
|
||||||
// 3. Gültiger Pfad
|
// 3. Gültiger Pfad
|
||||||
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
|
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
|
||||||
@@ -1313,4 +1695,209 @@ mod tests {
|
|||||||
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
||||||
assert!(!chunk.ciphertext.is_empty());
|
assert!(!chunk.ciphertext.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v02_missing_chunk_returns_error_instead_of_silent_truncation() {
|
||||||
|
let (fs, _dir) = create_test_fs(true);
|
||||||
|
let path = DavPath::new("/corrupt_test.bin").unwrap();
|
||||||
|
|
||||||
|
// 1. Datei mit 3 Chunks anlegen (3 * 1 MB = 3 MB)
|
||||||
|
let mut opts = OpenOptions::default();
|
||||||
|
opts.write = true;
|
||||||
|
opts.create_new = true;
|
||||||
|
let mut file = fs.open(&path, opts).await.unwrap();
|
||||||
|
|
||||||
|
let chunk_data = vec![0xABu8; CHUNK_SIZE];
|
||||||
|
for _ in 0..3 {
|
||||||
|
file.write_bytes(Bytes::copy_from_slice(&chunk_data))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
file.flush().await.unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let node = fs.resolve_path("/corrupt_test.bin").unwrap().unwrap();
|
||||||
|
assert_eq!(node.size, 3 * CHUNK_SIZE as u64);
|
||||||
|
|
||||||
|
// 2. Chunk 1 direkt per SQL aus der chunks-Tabelle löschen (stille Datenkorruption)
|
||||||
|
{
|
||||||
|
let conn = fs.db.conn_for_test();
|
||||||
|
let deleted = conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM chunks WHERE node_id = ?1 AND chunk_index = 1",
|
||||||
|
[node.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deleted, 1, "Chunk 1 muss gelöscht worden sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datei im VFS zum Lesen öffnen
|
||||||
|
let mut read_opts = OpenOptions::default();
|
||||||
|
read_opts.read = true;
|
||||||
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
||||||
|
|
||||||
|
// Erstes Megabyte (Chunk 0) lesen -> muss klappen
|
||||||
|
let c0 = read_file.read_bytes(CHUNK_SIZE).await.unwrap();
|
||||||
|
assert_eq!(c0.len(), CHUNK_SIZE);
|
||||||
|
|
||||||
|
// Zweites Megabyte (fehlender Chunk 1) lesen -> V-02 MUSS FsError::GeneralFailure zurückgeben!
|
||||||
|
let c1_res = read_file.read_bytes(CHUNK_SIZE).await;
|
||||||
|
assert!(
|
||||||
|
matches!(c1_res, Err(FsError::GeneralFailure)),
|
||||||
|
"Lesen eines gelöschten/fehlenden Chunks muss mit GeneralFailure fehlschlagen, nicht stillschweigend gekürzt werden! Erhalten: {:?}",
|
||||||
|
c1_res
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Test jenseits der Dateigröße bei leeren Dateien: kein Regress
|
||||||
|
let empty_path = DavPath::new("/empty.txt").unwrap();
|
||||||
|
let mut empty_opts = OpenOptions::default();
|
||||||
|
empty_opts.create_new = true;
|
||||||
|
empty_opts.write = true;
|
||||||
|
let mut empty_file = fs.open(&empty_path, empty_opts).await.unwrap();
|
||||||
|
empty_file.flush().await.unwrap();
|
||||||
|
drop(empty_file);
|
||||||
|
|
||||||
|
let mut read_empty_opts = OpenOptions::default();
|
||||||
|
read_empty_opts.read = true;
|
||||||
|
let mut read_empty = fs.open(&empty_path, read_empty_opts).await.unwrap();
|
||||||
|
let bytes = read_empty.read_bytes(100).await.unwrap();
|
||||||
|
assert!(bytes.is_empty(), "Leere Datei liefert 0 Bytes ohne Fehler");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v05_sanctum_file_drop_flushes_dirty_chunk_automatically() {
|
||||||
|
let (fs, _dir) = create_test_fs(true);
|
||||||
|
let path = DavPath::new("/drop_flush_test.bin").unwrap();
|
||||||
|
|
||||||
|
let mut opts = OpenOptions::default();
|
||||||
|
opts.write = true;
|
||||||
|
opts.create_new = true;
|
||||||
|
let mut file = fs.open(&path, opts).await.unwrap();
|
||||||
|
|
||||||
|
let test_data = b"Autoflush on drop without explicit flush() call (V-05)";
|
||||||
|
file.write_bytes(Bytes::copy_from_slice(test_data))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Absichtlich KEIN file.flush().await aufrufen!
|
||||||
|
// Drop des Handles muss Daten & Größe zwingend automatisch persistieren.
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// Prüfe, ob Datei in DB existiert und korrekte Größe hat
|
||||||
|
let node = fs.resolve_path("/drop_flush_test.bin").unwrap().unwrap();
|
||||||
|
assert_eq!(node.size, test_data.len() as u64);
|
||||||
|
|
||||||
|
// Lese Datei neu ein und verifiziere Inhalt
|
||||||
|
let mut read_opts = OpenOptions::default();
|
||||||
|
read_opts.read = true;
|
||||||
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
||||||
|
let read_bytes = read_file.read_bytes(test_data.len()).await.unwrap();
|
||||||
|
assert_eq!(&read_bytes[..], test_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v06_sparse_write_zero_fills_gap() {
|
||||||
|
use std::io::SeekFrom;
|
||||||
|
|
||||||
|
let (fs, _dir) = create_test_fs(true);
|
||||||
|
let path = DavPath::new("/sparse_test.bin").unwrap();
|
||||||
|
|
||||||
|
let mut opts = OpenOptions::default();
|
||||||
|
opts.write = true;
|
||||||
|
opts.create_new = true;
|
||||||
|
let mut file = fs.open(&path, opts).await.unwrap();
|
||||||
|
|
||||||
|
// 1. Schreibe 5 Bytes am Anfang (Chunk 0)
|
||||||
|
file.write_bytes(Bytes::copy_from_slice(b"START"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 2. Springe weit über das Dateiende hinaus in Chunk 2 (2,5 MB)
|
||||||
|
let seek_pos = 2 * CHUNK_SIZE as u64 + 500;
|
||||||
|
file.seek(SeekFrom::Start(seek_pos)).await.unwrap();
|
||||||
|
|
||||||
|
// 3. Schreibe 3 Bytes an Position seek_pos
|
||||||
|
file.write_bytes(Bytes::copy_from_slice(b"END"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file.flush().await.unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// 4. Verifiziere Dateigröße
|
||||||
|
let node = fs.resolve_path("/sparse_test.bin").unwrap().unwrap();
|
||||||
|
assert_eq!(node.size, seek_pos + 3);
|
||||||
|
|
||||||
|
// 5. Lese Datei vollständig ein und prüfe Nullen in der Lücke
|
||||||
|
let mut read_opts = OpenOptions::default();
|
||||||
|
read_opts.read = true;
|
||||||
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
||||||
|
|
||||||
|
// Anfang lesen
|
||||||
|
let start_bytes = read_file.read_bytes(5).await.unwrap();
|
||||||
|
assert_eq!(&start_bytes[..], b"START");
|
||||||
|
|
||||||
|
// Lücke in Chunk 0 und 1 muss mit Nullen gefüllt sein
|
||||||
|
let zero_gap = read_file.read_bytes(1000).await.unwrap();
|
||||||
|
assert_eq!(zero_gap.len(), 1000);
|
||||||
|
assert!(zero_gap.iter().all(|&b| b == 0));
|
||||||
|
|
||||||
|
// Springe zum Ende und lies die geschriebenen Daten
|
||||||
|
read_file.seek(SeekFrom::Start(seek_pos)).await.unwrap();
|
||||||
|
let end_bytes = read_file.read_bytes(3).await.unwrap();
|
||||||
|
assert_eq!(&end_bytes[..], b"END");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v07_copy_overwrite_existing_file_semantics() {
|
||||||
|
let (fs, _dir) = create_test_fs(true);
|
||||||
|
let src_path = DavPath::new("/source.txt").unwrap();
|
||||||
|
let dest_path = DavPath::new("/dest.txt").unwrap();
|
||||||
|
|
||||||
|
// 1. Erstelle Quelldatei
|
||||||
|
let mut opts1 = OpenOptions::default();
|
||||||
|
opts1.write = true;
|
||||||
|
opts1.create_new = true;
|
||||||
|
let mut src_file = fs.open(&src_path, opts1).await.unwrap();
|
||||||
|
let src_data = b"Freshly copied source data (RFC-4918)";
|
||||||
|
src_file
|
||||||
|
.write_bytes(Bytes::copy_from_slice(src_data))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
src_file.flush().await.unwrap();
|
||||||
|
drop(src_file);
|
||||||
|
|
||||||
|
// 2. Erstelle Zieldatei mit abweichendem Inhalt und Größe
|
||||||
|
let mut opts2 = OpenOptions::default();
|
||||||
|
opts2.write = true;
|
||||||
|
opts2.create_new = true;
|
||||||
|
let mut dest_file = fs.open(&dest_path, opts2).await.unwrap();
|
||||||
|
let old_dest_data = b"Old obsolete destination data that MUST be overwritten";
|
||||||
|
dest_file
|
||||||
|
.write_bytes(Bytes::copy_from_slice(old_dest_data))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
dest_file.flush().await.unwrap();
|
||||||
|
drop(dest_file);
|
||||||
|
|
||||||
|
// 3. Kopiere Quelle auf existierendes Ziel (RFC-4918 Copy Overwrite)
|
||||||
|
fs.copy(&src_path, &dest_path)
|
||||||
|
.await
|
||||||
|
.expect("Copy with overwrite must succeed per RFC-4918");
|
||||||
|
|
||||||
|
// 4. Verifiziere Zielinhalt
|
||||||
|
let mut read_opts = OpenOptions::default();
|
||||||
|
read_opts.read = true;
|
||||||
|
let mut read_dest = fs.open(&dest_path, read_opts).await.unwrap();
|
||||||
|
let read_bytes = read_dest.read_bytes(src_data.len() + 100).await.unwrap();
|
||||||
|
assert_eq!(&read_bytes[..], src_data);
|
||||||
|
|
||||||
|
let dest_node = fs.resolve_path("/dest.txt").unwrap().unwrap();
|
||||||
|
assert_eq!(dest_node.size, src_data.len() as u64);
|
||||||
|
|
||||||
|
// 5. Test: Kopieren auf existierendes Verzeichnis muss mit Forbidden fehlschlagen
|
||||||
|
let dir_path = DavPath::new("/sub_dir").unwrap();
|
||||||
|
fs.create_dir(&dir_path).await.unwrap();
|
||||||
|
let copy_to_dir_res = fs.copy(&src_path, &dir_path).await;
|
||||||
|
assert!(matches!(copy_to_dir_res, Err(FsError::Forbidden)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-1
@@ -819,6 +819,84 @@ pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prüft, ob ein Prozess mit der angegebenen PID auf dem lokalen System aktiv ist (S-06).
|
||||||
|
pub fn is_process_alive(pid: u32) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
extern "system" {
|
||||||
|
fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> isize;
|
||||||
|
fn CloseHandle(handle: isize) -> i32;
|
||||||
|
fn GetExitCodeProcess(handle: isize, exit_code: *mut u32) -> i32;
|
||||||
|
}
|
||||||
|
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||||||
|
const STILL_ACTIVE: u32 = 259;
|
||||||
|
|
||||||
|
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||||
|
if handle == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut exit_code: u32 = 0;
|
||||||
|
let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
|
||||||
|
unsafe { CloseHandle(handle) };
|
||||||
|
success != 0 && exit_code == STILL_ACTIVE
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
std::path::Path::new(&format!("/proc/{}", pid)).exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt den verfügbaren freien Speicherplatz auf dem Dateisystem des angegebenen Pfads (Z-04).
|
||||||
|
pub fn get_available_disk_space<P: AsRef<std::path::Path>>(path: P) -> Option<u64> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
let p = path.as_ref();
|
||||||
|
let dir = if p.is_dir() {
|
||||||
|
p.to_path_buf()
|
||||||
|
} else {
|
||||||
|
p.parent()
|
||||||
|
.map(|parent| parent.to_path_buf())
|
||||||
|
.unwrap_or_else(|| p.to_path_buf())
|
||||||
|
};
|
||||||
|
let mut wide: Vec<u16> = dir.as_os_str().encode_wide().collect();
|
||||||
|
wide.push(0);
|
||||||
|
|
||||||
|
let mut free_bytes_available = 0u64;
|
||||||
|
let mut total_number_of_bytes = 0u64;
|
||||||
|
let mut total_number_of_free_bytes = 0u64;
|
||||||
|
|
||||||
|
extern "system" {
|
||||||
|
fn GetDiskFreeSpaceExW(
|
||||||
|
lpDirectoryName: *const u16,
|
||||||
|
lpFreeBytesAvailableToCaller: *mut u64,
|
||||||
|
lpTotalNumberOfBytes: *mut u64,
|
||||||
|
lpTotalNumberOfFreeBytes: *mut u64,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = unsafe {
|
||||||
|
GetDiskFreeSpaceExW(
|
||||||
|
wide.as_ptr(),
|
||||||
|
&mut free_bytes_available,
|
||||||
|
&mut total_number_of_bytes,
|
||||||
|
&mut total_number_of_free_bytes,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if success != 0 {
|
||||||
|
Some(free_bytes_available)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
Some(1024 * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -835,7 +913,7 @@ mod tests {
|
|||||||
fn test_find_next_available_drive() {
|
fn test_find_next_available_drive() {
|
||||||
let drive = find_next_available_drive().expect("Find next drive");
|
let drive = find_next_available_drive().expect("Find next drive");
|
||||||
assert!(drive.is_ascii_alphabetic());
|
assert!(drive.is_ascii_alphabetic());
|
||||||
assert!(drive >= 'D' && drive <= 'Z');
|
assert!(('D'..='Z').contains(&drive));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -575,3 +575,351 @@ async fn test_carrier_file_drop_and_append_mode() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v01_carrier_fs_copy_delegation_and_functionality() {
|
||||||
|
let path = temp_db_path("v01_copy");
|
||||||
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
let carrier_name = "carrier_for_copy.bin";
|
||||||
|
|
||||||
|
let pass_decoy = "DecoyPass2026!";
|
||||||
|
let pass_hidden = "HiddenPass2026!";
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
db.init_schema_with_carrier(
|
||||||
|
&salt_0,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_0,
|
||||||
|
&nonce_0,
|
||||||
|
&tag_0,
|
||||||
|
Some((
|
||||||
|
carrier_name,
|
||||||
|
carrier_size_bytes,
|
||||||
|
&salt_1,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_1,
|
||||||
|
&nonce_1,
|
||||||
|
&tag_1,
|
||||||
|
&dek_0,
|
||||||
|
&dek_1,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.expect("Init carrier schema");
|
||||||
|
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
let meta = db.read_meta().unwrap();
|
||||||
|
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||||
|
|
||||||
|
let hidden_fs = SanctumFs::with_carrier(
|
||||||
|
db.clone(),
|
||||||
|
auth_hidden.dek().clone(),
|
||||||
|
auth_hidden.carrier_dek(),
|
||||||
|
auth_hidden.carrier_node_id(),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordner anlegen
|
||||||
|
let folder = DavPath::new("/work").unwrap();
|
||||||
|
hidden_fs.create_dir(&folder).await.expect("Create folder");
|
||||||
|
|
||||||
|
// Datei anlegen und Inhalt schreiben
|
||||||
|
let src_path = DavPath::new("/work/original.txt").unwrap();
|
||||||
|
let test_data = b"V01 CarrierFs Copy Functionality Test Payload 2026";
|
||||||
|
let mut file = hidden_fs
|
||||||
|
.open(
|
||||||
|
&src_path,
|
||||||
|
OpenOptions {
|
||||||
|
create: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Create source file");
|
||||||
|
file.write_bytes(Bytes::from_static(test_data))
|
||||||
|
.await
|
||||||
|
.expect("Write source data");
|
||||||
|
file.flush().await.expect("Flush source file");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// V-01: copy von /work/original.txt nach /work/copy.txt aufrufen
|
||||||
|
let dest_path = DavPath::new("/work/copy.txt").unwrap();
|
||||||
|
hidden_fs
|
||||||
|
.copy(&src_path, &dest_path)
|
||||||
|
.await
|
||||||
|
.expect("V-01: Copy must succeed on CarrierFs");
|
||||||
|
|
||||||
|
// Metadaten der Kopie prüfen
|
||||||
|
let copy_meta = hidden_fs
|
||||||
|
.metadata(&dest_path)
|
||||||
|
.await
|
||||||
|
.expect("Metadata of copied file");
|
||||||
|
assert_eq!(copy_meta.len(), test_data.len() as u64);
|
||||||
|
assert!(!copy_meta.is_dir());
|
||||||
|
|
||||||
|
// Inhalt der Kopie lesen und verifizieren
|
||||||
|
let mut read_copy = hidden_fs
|
||||||
|
.open(
|
||||||
|
&dest_path,
|
||||||
|
OpenOptions {
|
||||||
|
read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Open copied file");
|
||||||
|
let copy_bytes = read_copy.read_bytes(1024).await.expect("Read copy bytes");
|
||||||
|
assert_eq!(©_bytes[..], test_data);
|
||||||
|
drop(read_copy);
|
||||||
|
|
||||||
|
// Quelldatei löschen: Die Kopie muss unabhängig erhalten bleiben!
|
||||||
|
hidden_fs
|
||||||
|
.remove_file(&src_path)
|
||||||
|
.await
|
||||||
|
.expect("Delete source file");
|
||||||
|
assert!(hidden_fs.metadata(&src_path).await.is_err());
|
||||||
|
|
||||||
|
let mut read_copy_again = hidden_fs
|
||||||
|
.open(
|
||||||
|
&dest_path,
|
||||||
|
OpenOptions {
|
||||||
|
read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Open copied file after source delete");
|
||||||
|
let copy_bytes_2 = read_copy_again
|
||||||
|
.read_bytes(1024)
|
||||||
|
.await
|
||||||
|
.expect("Read copy bytes after source delete");
|
||||||
|
assert_eq!(©_bytes_2[..], test_data);
|
||||||
|
drop(read_copy_again);
|
||||||
|
|
||||||
|
// Overwrite-Copy testen
|
||||||
|
let src2_path = DavPath::new("/work/source2.txt").unwrap();
|
||||||
|
let test_data_2 = b"New Overwrite Payload Content";
|
||||||
|
let mut file2 = hidden_fs
|
||||||
|
.open(
|
||||||
|
&src2_path,
|
||||||
|
OpenOptions {
|
||||||
|
create: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Create source2");
|
||||||
|
file2
|
||||||
|
.write_bytes(Bytes::from_static(test_data_2))
|
||||||
|
.await
|
||||||
|
.expect("Write source2");
|
||||||
|
file2.flush().await.expect("Flush source2");
|
||||||
|
drop(file2);
|
||||||
|
|
||||||
|
hidden_fs
|
||||||
|
.copy(&src2_path, &dest_path)
|
||||||
|
.await
|
||||||
|
.expect("Overwrite copy must succeed");
|
||||||
|
|
||||||
|
let mut read_overwritten = hidden_fs
|
||||||
|
.open(
|
||||||
|
&dest_path,
|
||||||
|
OpenOptions {
|
||||||
|
read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Open overwritten copy");
|
||||||
|
let overwritten_bytes = read_overwritten
|
||||||
|
.read_bytes(1024)
|
||||||
|
.await
|
||||||
|
.expect("Read overwritten bytes");
|
||||||
|
assert_eq!(&overwritten_bytes[..], test_data_2);
|
||||||
|
drop(read_overwritten);
|
||||||
|
|
||||||
|
// Checkpoint & Verify
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
|
||||||
|
assert!(
|
||||||
|
report.is_healthy(),
|
||||||
|
"Container muss integer und gesund bleiben: {:?}",
|
||||||
|
report.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m05_deniability_schema_equality_standard_vs_hidden() {
|
||||||
|
let path_std = temp_db_path("schema_std");
|
||||||
|
let path_hidden = temp_db_path("schema_hidden");
|
||||||
|
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt_std = generate_salt();
|
||||||
|
let kek_std = derive_kek("StandardPass123!", &salt_std, &kdf_params).unwrap();
|
||||||
|
let dek_std = generate_dek();
|
||||||
|
let (wrapped_std, nonce_std, tag_std) = wrap_slot0_payload(&kek_std, &dek_std, 0).unwrap();
|
||||||
|
|
||||||
|
let db_std = Database::open(&path_std).expect("Open std db");
|
||||||
|
db_std
|
||||||
|
.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std)
|
||||||
|
.expect("Init std schema");
|
||||||
|
db_std.checkpoint().unwrap();
|
||||||
|
|
||||||
|
let salt_0 = generate_salt();
|
||||||
|
let kek_0 = derive_kek("DecoyPass123!", &salt_0, &kdf_params).unwrap();
|
||||||
|
let dek_0 = generate_dek();
|
||||||
|
|
||||||
|
let salt_1 = generate_salt();
|
||||||
|
let kek_1 = derive_kek("HiddenPass123!", &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_hidden = Database::open(&path_hidden).expect("Open hidden db");
|
||||||
|
db_hidden
|
||||||
|
.init_schema_with_carrier(
|
||||||
|
&salt_0,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_0,
|
||||||
|
&nonce_0,
|
||||||
|
&tag_0,
|
||||||
|
Some((
|
||||||
|
"carrier.dat",
|
||||||
|
1024 * 1024,
|
||||||
|
&salt_1,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_1,
|
||||||
|
&nonce_1,
|
||||||
|
&tag_1,
|
||||||
|
&dek_0,
|
||||||
|
&dek_1,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.expect("Init carrier schema");
|
||||||
|
db_hidden.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// Vergleiche Schemas via rusqlite
|
||||||
|
let conn_std = rusqlite::Connection::open(&path_std).unwrap();
|
||||||
|
let conn_hidden = rusqlite::Connection::open(&path_hidden).unwrap();
|
||||||
|
|
||||||
|
let get_schema_elements =
|
||||||
|
|conn: &rusqlite::Connection| -> Vec<(String, String, Option<String>)> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT type, name, sql FROM sqlite_master
|
||||||
|
WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY type, name",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.map(|r| r.unwrap()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let schema_std = get_schema_elements(&conn_std);
|
||||||
|
let schema_hidden = get_schema_elements(&conn_hidden);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
schema_std, schema_hidden,
|
||||||
|
"M-05: SQLite-Schema (Tabellen, Indizes, DDL) muss zwischen Standard- und Hidden-Vault identisch sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spalten- und Typinformationen vergleichen
|
||||||
|
for table in &["meta", "nodes", "chunks"] {
|
||||||
|
let get_table_info =
|
||||||
|
|conn: &rusqlite::Connection| -> Vec<(i64, String, String, i64, Option<String>, i64)> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({});", table))
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, i64>(3)?,
|
||||||
|
row.get::<_, Option<String>>(4)?,
|
||||||
|
row.get::<_, i64>(5)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.map(|r| r.unwrap()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols_std = get_table_info(&conn_std);
|
||||||
|
let cols_hidden = get_table_info(&conn_hidden);
|
||||||
|
assert_eq!(
|
||||||
|
cols_std, cols_hidden,
|
||||||
|
"M-05: Spalten und Typen für Tabelle '{}' müssen identisch sein",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pragmas vergleichen (page_size, auto_vacuum, secure_delete)
|
||||||
|
let get_pragma = |conn: &rusqlite::Connection, pragma: &str| -> i64 {
|
||||||
|
conn.query_row(&format!("PRAGMA {};", pragma), [], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_pragma(&conn_std, "page_size"),
|
||||||
|
get_pragma(&conn_hidden, "page_size")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_pragma(&conn_std, "auto_vacuum"),
|
||||||
|
get_pragma(&conn_hidden, "auto_vacuum")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_pragma(&conn_std, "secure_delete"),
|
||||||
|
get_pragma(&conn_hidden, "secure_delete")
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(conn_std);
|
||||||
|
drop(conn_hidden);
|
||||||
|
drop(db_std);
|
||||||
|
drop(db_hidden);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path_std);
|
||||||
|
let _ = std::fs::remove_file(&path_hidden);
|
||||||
|
}
|
||||||
|
|||||||
+246
-3
@@ -13,7 +13,10 @@ use sanctum::{
|
|||||||
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB,
|
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB,
|
||||||
MIN_TIME_COST,
|
MIN_TIME_COST,
|
||||||
},
|
},
|
||||||
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
recovery::{
|
||||||
|
export_header_backup, rekey_container, restore_header_backup,
|
||||||
|
restore_header_from_recovery_key,
|
||||||
|
},
|
||||||
storage::Database,
|
storage::Database,
|
||||||
verify::verify_container,
|
verify::verify_container,
|
||||||
vfs::SanctumFs,
|
vfs::SanctumFs,
|
||||||
@@ -433,8 +436,8 @@ async fn test_sanctum_v1_backward_compatibility() {
|
|||||||
// V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben
|
// V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben
|
||||||
let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload.";
|
let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload.";
|
||||||
let (ct, nonce, tag) =
|
let (ct, nonce, tag) =
|
||||||
encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1");
|
encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1, 0).expect("encrypt v1");
|
||||||
db.write_chunk(node.id, 0, &nonce, &tag, &ct)
|
db.write_chunk(node.id, 0, 0, &nonce, &tag, &ct)
|
||||||
.expect("write chunk");
|
.expect("write chunk");
|
||||||
db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678)
|
db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678)
|
||||||
.expect("update size");
|
.expect("update size");
|
||||||
@@ -1561,3 +1564,243 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
let _ = std::fs::remove_file(&container_path);
|
let _ = std::fs::remove_file(&container_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf = temp_dir.join(format!(
|
||||||
|
"test_sanctum_k03_{}_{}.sanctum",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
if container_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_v1 = "InitialPassword2026!";
|
||||||
|
let password_v2 = "ChangedPassword2026!";
|
||||||
|
|
||||||
|
// 1. Initialisiere 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(password_v1, &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&container_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||||
|
.unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// 2. Schreibe eine Testdatei
|
||||||
|
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
||||||
|
let secret_path = DavPath::new("/secret_doc.txt").unwrap();
|
||||||
|
let mut opts = OpenOptions::default();
|
||||||
|
opts.write = true;
|
||||||
|
opts.create_new = true;
|
||||||
|
let mut f = fs.open(&secret_path, opts).await.unwrap();
|
||||||
|
let secret_data = b"Confidential financial data protected by Sanctum K-03.";
|
||||||
|
f.write_buf(Box::new(Bytes::from_static(secret_data)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(f);
|
||||||
|
drop(fs);
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// Initiales Notfallblatt
|
||||||
|
let initial_mnemonic = dek_to_mnemonic(&dek).unwrap();
|
||||||
|
|
||||||
|
// 3. Simuliere normales Passwort-Ändern (sanctum passwd)
|
||||||
|
// Bei passwd wird der DEK beibehalten, nur mit neuem KEK verpackt
|
||||||
|
let new_salt = generate_salt();
|
||||||
|
let new_kek = derive_kek(password_v2, &new_salt, &kdf_params).unwrap();
|
||||||
|
let (new_wrapped_dek, new_nonce, new_tag) =
|
||||||
|
sanctum::crypto::wrap_slot0_payload(&new_kek, &dek, 0).unwrap();
|
||||||
|
db.update_slot_keys(
|
||||||
|
0,
|
||||||
|
&new_salt,
|
||||||
|
&kdf_params,
|
||||||
|
&new_wrapped_dek,
|
||||||
|
&new_nonce,
|
||||||
|
&new_tag,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// Verifiziere: Das ursprüngliche Notfallblatt ist WEITERHIN gültig nach normalem passwd!
|
||||||
|
// (Beweis für Finding K-03, weshalb Warnung + Rekeying notwendig sind)
|
||||||
|
{
|
||||||
|
// Lösche Header und stelle mit initial_mnemonic + temp password wieder her
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
conn.execute("DELETE FROM meta", []).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "TempPass2026!")
|
||||||
|
.expect("Altes Notfallblatt muss nach passwd weiterhin funktionieren");
|
||||||
|
|
||||||
|
let r_db = Database::open(&container_path).unwrap();
|
||||||
|
let r_meta = r_db.read_meta().unwrap();
|
||||||
|
let r_kek = derive_kek("TempPass2026!", &r_meta.kdf_salt, &r_meta.kdf_params).unwrap();
|
||||||
|
let r_dek = unwrap_dek(
|
||||||
|
&r_kek,
|
||||||
|
&r_meta.wrapped_dek,
|
||||||
|
&r_meta.header_nonce,
|
||||||
|
&r_meta.header_tag,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*dek, *r_dek,
|
||||||
|
"DEK muss nach normalem passwd identisch geblieben sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
let r_fs = SanctumFs::new(r_db.clone(), r_dek, r_meta.version);
|
||||||
|
let mut r_opts = OpenOptions::default();
|
||||||
|
r_opts.read = true;
|
||||||
|
let mut r_f = r_fs.open(&secret_path, r_opts).await.unwrap();
|
||||||
|
let read_bytes = r_f.read_bytes(secret_data.len()).await.unwrap();
|
||||||
|
assert_eq!(&read_bytes[..], secret_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze Header wieder auf password_v2
|
||||||
|
let fresh_salt = generate_salt();
|
||||||
|
let fresh_kek = derive_kek(password_v2, &fresh_salt, &kdf_params).unwrap();
|
||||||
|
let (fresh_wrapped, fresh_nonce, fresh_tag) =
|
||||||
|
sanctum::crypto::wrap_slot0_payload(&fresh_kek, &dek, 0).unwrap();
|
||||||
|
db.update_slot_keys(
|
||||||
|
0,
|
||||||
|
&fresh_salt,
|
||||||
|
&kdf_params,
|
||||||
|
&fresh_wrapped,
|
||||||
|
&fresh_nonce,
|
||||||
|
&fresh_tag,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// 4. Jetzt: Führe echtes Rekeying aus (sanctum rekey)
|
||||||
|
let rekeyed_mnemonic = rekey_container(&container_path, password_v2)
|
||||||
|
.expect("Rekeying muss erfolgreich durchlaufen");
|
||||||
|
assert_ne!(
|
||||||
|
initial_mnemonic, rekeyed_mnemonic,
|
||||||
|
"Neues Notfallblatt muss sich unterscheiden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Container lässt sich mit neuem Passwort öffnen und Daten sind intakt
|
||||||
|
let rekeyed_db = Database::open(&container_path).unwrap();
|
||||||
|
let rekeyed_meta = rekeyed_db.read_meta().unwrap();
|
||||||
|
let rekeyed_kek = derive_kek(
|
||||||
|
password_v2,
|
||||||
|
&rekeyed_meta.kdf_salt,
|
||||||
|
&rekeyed_meta.kdf_params,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rekeyed_dek = unwrap_dek(
|
||||||
|
&rekeyed_kek,
|
||||||
|
&rekeyed_meta.wrapped_dek,
|
||||||
|
&rekeyed_meta.header_nonce,
|
||||||
|
&rekeyed_meta.header_tag,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
*dek, *rekeyed_dek,
|
||||||
|
"DEK muss nach Rekeying erneuert worden sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rekeyed_fs = SanctumFs::new(
|
||||||
|
rekeyed_db.clone(),
|
||||||
|
rekeyed_dek.clone(),
|
||||||
|
rekeyed_meta.version,
|
||||||
|
);
|
||||||
|
let mut check_opts = OpenOptions::default();
|
||||||
|
check_opts.read = true;
|
||||||
|
let mut check_f = rekeyed_fs.open(&secret_path, check_opts).await.unwrap();
|
||||||
|
let rekeyed_data = check_f.read_bytes(secret_data.len()).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&rekeyed_data[..],
|
||||||
|
secret_data,
|
||||||
|
"Daten müssen nach Rekeying vollständig intakt sein"
|
||||||
|
);
|
||||||
|
drop(check_f);
|
||||||
|
drop(rekeyed_fs);
|
||||||
|
|
||||||
|
// Integritätsprüfung nach Rekeying
|
||||||
|
let report = verify_container(&container_path, Some(&rekeyed_dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
report.is_healthy(),
|
||||||
|
"Container muss nach Rekeying gesunden Status aufweisen"
|
||||||
|
);
|
||||||
|
assert_eq!(report.corrupted_chunks, 0);
|
||||||
|
|
||||||
|
// 6. Altes Notfallblatt ist entwertet: Kann umverschlüsselte Chunks NICHT mehr entschlüsseln!
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
conn.execute("DELETE FROM meta", []).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Versuch, mit altem Notfallblatt wiederherzustellen
|
||||||
|
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "OldKeyPassword!")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let old_res_db = Database::open(&container_path).unwrap();
|
||||||
|
let old_res_meta = old_res_db.read_meta().unwrap();
|
||||||
|
let old_res_kek = derive_kek(
|
||||||
|
"OldKeyPassword!",
|
||||||
|
&old_res_meta.kdf_salt,
|
||||||
|
&old_res_meta.kdf_params,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let old_res_dek = unwrap_dek(
|
||||||
|
&old_res_kek,
|
||||||
|
&old_res_meta.wrapped_dek,
|
||||||
|
&old_res_meta.header_nonce,
|
||||||
|
&old_res_meta.header_tag,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Entschlüsselung der Datei mit dem alten DEK muss scheitern (AEAD tag mismatch)
|
||||||
|
let old_fs = SanctumFs::new(
|
||||||
|
old_res_db.clone(),
|
||||||
|
old_res_dek.clone(),
|
||||||
|
old_res_meta.version,
|
||||||
|
);
|
||||||
|
let mut fail_opts = OpenOptions::default();
|
||||||
|
fail_opts.read = true;
|
||||||
|
let mut fail_f = old_fs.open(&secret_path, fail_opts).await.unwrap();
|
||||||
|
let fail_res = fail_f.read_bytes(secret_data.len()).await;
|
||||||
|
assert!(
|
||||||
|
fail_res.is_err(),
|
||||||
|
"Altes Notfallblatt darf nach Rekeying Chunks nicht mehr entschlüsseln können!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auch verify_container mit altem DEK meldet Korruption / MAC-Fehlschlag
|
||||||
|
let fail_report = verify_container(&container_path, Some(&old_res_dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
!fail_report.is_healthy(),
|
||||||
|
"Prüfung mit altem Notfallschlüssel muss Fehler melden"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufräumen
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m02_release_profile_enables_overflow_checks() {
|
||||||
|
let cargo_toml = std::fs::read_to_string("Cargo.toml").expect("Read Cargo.toml");
|
||||||
|
let release_section = cargo_toml
|
||||||
|
.split("[profile.release]")
|
||||||
|
.nth(1)
|
||||||
|
.expect("Must have [profile.release] section");
|
||||||
|
let release_block = release_section.split('[').next().unwrap_or(release_section);
|
||||||
|
assert!(
|
||||||
|
release_block.contains("overflow-checks = true"),
|
||||||
|
"M-02: [profile.release] must explicitly set overflow-checks = true to prevent integer overflow vulnerabilities"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,3 +154,158 @@ fn test_mount_security_multi_auth_and_no_token_in_url() {
|
|||||||
"Saubere URI darf kein Token enthalten"
|
"Saubere URI darf kein Token enthalten"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_vfs_memory_lock_retention_on_clone_v03() {
|
||||||
|
use dav_server::davpath::DavPath;
|
||||||
|
use dav_server::fs::{DavFileSystem, ReadDirMeta};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use sanctum::vfs::SanctumFs;
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf =
|
||||||
|
temp_dir.join(format!("test_v03_lock_{}.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("TestPassV03!", &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();
|
||||||
|
|
||||||
|
// 1. Initialisiere SanctumFs
|
||||||
|
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
|
||||||
|
assert_eq!(
|
||||||
|
fs.dek_strong_count(),
|
||||||
|
1,
|
||||||
|
"Anfangs muss genau 1 Referenz auf den verriegelten DEK existieren"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Klone SanctumFs (wie bei jeder HTTP/WebDAV-Anfrage im Server)
|
||||||
|
let fs_clone = fs.clone();
|
||||||
|
assert_eq!(
|
||||||
|
fs.dek_strong_count(),
|
||||||
|
2,
|
||||||
|
"Nach dem Klonen müssen 2 Referenzen existieren"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Droppe den Klon
|
||||||
|
drop(fs_clone);
|
||||||
|
|
||||||
|
// 4. V-03: Der Refcount muss nun wieder 1 sein. Der Speicherbereich darf NICHT
|
||||||
|
// vorzeitig über Drop eines Klons entriegelt worden sein!
|
||||||
|
assert_eq!(
|
||||||
|
fs.dek_strong_count(),
|
||||||
|
1,
|
||||||
|
"Nach Drop des Klons muss genau 1 Referenz erhalten bleiben"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verifiziere funktionale Nutzbarkeit nach Drop des Klons
|
||||||
|
let root_path = DavPath::new("/").unwrap();
|
||||||
|
let mut entries = fs.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||||
|
assert!(entries.next().await.is_none());
|
||||||
|
|
||||||
|
drop(fs);
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_z01_decoy_password_zeroize_memory() {
|
||||||
|
use sanctum::mount::ContainerAuth;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
// Test that ContainerAuth properly encapsulates Zeroizing credentials
|
||||||
|
let raw_pass = "TopSecretDecoyPass2026!".to_string();
|
||||||
|
let zeroized_pass = Zeroizing::new(raw_pass.clone());
|
||||||
|
let auth = ContainerAuth::Password(zeroized_pass.clone());
|
||||||
|
|
||||||
|
if let ContainerAuth::Password(ref p) = auth {
|
||||||
|
assert_eq!(p.as_str(), raw_pass.as_str());
|
||||||
|
} else {
|
||||||
|
panic!("ContainerAuth muss Password-Variante enthalten");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Zeroizing cleans memory when dropped
|
||||||
|
let ephemeral = Zeroizing::new(String::from("DecoyPassInHeap"));
|
||||||
|
assert_eq!(&*ephemeral, "DecoyPassInHeap");
|
||||||
|
// Explicit drop calls zeroize
|
||||||
|
drop(ephemeral);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_z04_webdav_quota_report() {
|
||||||
|
use dav_server::fs::DavFileSystem;
|
||||||
|
use sanctum::vfs::SanctumFs;
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf =
|
||||||
|
temp_dir.join(format!("test_z04_quota_{}.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("TestPassZ04!", &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 einen Testknoten mit Chunks
|
||||||
|
let node = db.create_node(1, "payload.bin", false).unwrap();
|
||||||
|
let dummy_chunk = vec![0xAAu8; 1024 * 1024]; // 1 MiB
|
||||||
|
db.write_chunk_and_update_size(
|
||||||
|
node.id,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
&[0u8; 12],
|
||||||
|
&[0u8; 16],
|
||||||
|
&dummy_chunk,
|
||||||
|
1024 * 1024,
|
||||||
|
123456,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
|
||||||
|
|
||||||
|
// Rufe WebDAV get_quota ab
|
||||||
|
let (used, total_opt) = fs.get_quota().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
used > 0,
|
||||||
|
"Z-04: Verwendeter Speicherplatz muss > 0 sein (gemeldet: {} Bytes)",
|
||||||
|
used
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
total_opt.is_some(),
|
||||||
|
"Z-04: Gesamtkapazität muss gemeldet werden"
|
||||||
|
);
|
||||||
|
let total = total_opt.unwrap();
|
||||||
|
assert!(
|
||||||
|
total >= used,
|
||||||
|
"Z-04: Gesamtkapazität ({}) muss mindestens dem belegten Speicher ({}) entsprechen",
|
||||||
|
total,
|
||||||
|
used
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(fs);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
|
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
|
||||||
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
||||||
let local_paths = HashSet::new();
|
let local_paths = HashSet::new();
|
||||||
|
let excluded_paths = HashSet::new();
|
||||||
let mut stats = SyncStats::default();
|
let mut stats = SyncStats::default();
|
||||||
let orphan_res = delete_orphans_in_vault(
|
let orphan_res = delete_orphans_in_vault(
|
||||||
&db,
|
&db,
|
||||||
@@ -112,6 +113,9 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
1, // Root-Knoten
|
1, // Root-Knoten
|
||||||
"",
|
"",
|
||||||
&local_paths,
|
&local_paths,
|
||||||
|
&excluded_paths,
|
||||||
|
&[],
|
||||||
|
false, // delete_excluded: false
|
||||||
false, // kein dry_run
|
false, // kein dry_run
|
||||||
true, // quiet
|
true, // quiet
|
||||||
&mut stats,
|
&mut stats,
|
||||||
@@ -161,10 +165,14 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
let sync_options = sanctum::sync::SyncOptions {
|
let sync_options = sanctum::sync::SyncOptions {
|
||||||
direction: sanctum::sync::SyncDirection::Pull,
|
direction: sanctum::sync::SyncDirection::Pull,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
delete_excluded: false,
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: true,
|
quiet: true,
|
||||||
|
force: false,
|
||||||
|
backup: false,
|
||||||
|
update: false,
|
||||||
};
|
};
|
||||||
let pull_res = sanctum::sync::run_sync(
|
let pull_res = sanctum::sync::run_sync(
|
||||||
&db,
|
&db,
|
||||||
|
|||||||
@@ -269,3 +269,921 @@ fn test_sync_delete_and_exclude_flags() {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_root);
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s01_delete_preserves_excluded_files_and_directories() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s01_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test_s01.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::create_dir_all(source_dir.join("excluded_dir")).unwrap();
|
||||||
|
fs::write(source_dir.join("normal.txt"), b"Normal file").unwrap();
|
||||||
|
fs::write(source_dir.join("document.pdf"), b"Important PDF").unwrap();
|
||||||
|
fs::write(
|
||||||
|
source_dir.join("excluded_dir").join("subfile.txt"),
|
||||||
|
b"Subfile in excluded dir",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// 1. Initialer Push ALLER Dateien (ohne Exclude)
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Push;
|
||||||
|
opts.quiet = true;
|
||||||
|
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/Data",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(db
|
||||||
|
.resolve_path_in_vault("/Data/document.pdf", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
assert!(db
|
||||||
|
.resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
|
||||||
|
// 2. Lokale Kopie von document.pdf und excluded_dir entfernen
|
||||||
|
fs::remove_file(source_dir.join("document.pdf")).unwrap();
|
||||||
|
fs::remove_dir_all(source_dir.join("excluded_dir")).unwrap();
|
||||||
|
|
||||||
|
// 3. Sync Push mit --delete aber MIT --exclude "*.pdf" und --exclude "excluded_dir*"
|
||||||
|
opts.delete = true;
|
||||||
|
opts.delete_excluded = false;
|
||||||
|
opts.exclude_patterns = vec!["*.pdf".to_string(), "excluded_dir*".to_string()];
|
||||||
|
|
||||||
|
let stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/Data",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
stats.files_deleted, 0,
|
||||||
|
"Ausgeschlossene Dateien/Ordner dürfen NICHT gelöscht werden!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// PDF und excluded_dir Teilbaum müssen im Tresor überlebt haben!
|
||||||
|
assert!(
|
||||||
|
db.resolve_path_in_vault("/Data/document.pdf", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"PDF im Tresor muss überleben"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"Teilbaum in excluded_dir muss überleben"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Jetzt Push mit --delete UND --delete-excluded
|
||||||
|
opts.delete_excluded = true;
|
||||||
|
let stats_del = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/Data",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
stats_del.files_deleted >= 2,
|
||||||
|
"Mit --delete-excluded müssen die verwaisten ausgeschlossenen Dateien gelöscht werden"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.resolve_path_in_vault("/Data/document.pdf", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"PDF muss mit --delete-excluded gelöscht sein"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"excluded_dir Inhalt muss gelöscht sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s01_pull_delete_preserves_local_excluded_and_leak_files() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s01_pull_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test_s01_pull.sanctum");
|
||||||
|
let restore_dir = temp_root.join("restored");
|
||||||
|
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
// Lokale Dateien anlegen, die im Vault NICHT existieren: folder.jpg (Leak file) und local_notes.pdf (Ausschluss)
|
||||||
|
fs::write(
|
||||||
|
restore_dir.join("folder.jpg"),
|
||||||
|
b"Album Art or Explorer Cache",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(restore_dir.join("local_notes.pdf"), b"Private Local Notes").unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Eine Datei im Vault anlegen
|
||||||
|
let root_node = db.get_node_by_id_in_vault(1, 0, &dek).unwrap().unwrap();
|
||||||
|
let _ = db
|
||||||
|
.create_node_in_vault(0, root_node.id, "vault_file.txt", false, &dek)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Pull mit --delete und --exclude "*.pdf"
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Pull;
|
||||||
|
opts.delete = true;
|
||||||
|
opts.delete_excluded = false;
|
||||||
|
opts.exclude_patterns = vec!["*.pdf".to_string()];
|
||||||
|
opts.quiet = true;
|
||||||
|
|
||||||
|
let stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
restore_dir.to_str().unwrap(),
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
stats.files_deleted, 0,
|
||||||
|
"folder.jpg und local_notes.pdf dürfen bei Pull mit --delete NICHT gelöscht werden!"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("folder.jpg").exists(),
|
||||||
|
"folder.jpg muss auf dem Host erhalten bleiben"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("local_notes.pdf").exists(),
|
||||||
|
"local_notes.pdf muss auf dem Host erhalten bleiben"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pull mit --delete UND --delete-excluded
|
||||||
|
opts.delete_excluded = true;
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
restore_dir.to_str().unwrap(),
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!restore_dir.join("local_notes.pdf").exists(),
|
||||||
|
"local_notes.pdf muss mit --delete-excluded gelöscht werden"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s02_platform_independent_path_construction_and_traversal_rejection() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s02_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let target_dir = temp_root.join("restored");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
let deep_dir = source_dir.join("level1").join("level2").join("level3");
|
||||||
|
fs::create_dir_all(&deep_dir).unwrap();
|
||||||
|
fs::write(deep_dir.join("deep_doc.txt"), b"Deeply nested content").unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// 1. Push: Übertrage mehrstufige Verzeichnisstruktur in den Tresor
|
||||||
|
let mut push_opts = SyncOptions::default();
|
||||||
|
push_opts.direction = SyncDirection::Push;
|
||||||
|
push_opts.quiet = true;
|
||||||
|
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/MultiLevel",
|
||||||
|
&push_opts,
|
||||||
|
)
|
||||||
|
.expect("Sync push multi-level");
|
||||||
|
|
||||||
|
// 2. Pull: Prüfe, dass mehrstufige Pfade plattformunabhängig rekonstruiert werden
|
||||||
|
let mut pull_opts = SyncOptions::default();
|
||||||
|
pull_opts.direction = SyncDirection::Pull;
|
||||||
|
pull_opts.quiet = true;
|
||||||
|
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/MultiLevel",
|
||||||
|
target_dir.to_str().unwrap(),
|
||||||
|
&pull_opts,
|
||||||
|
)
|
||||||
|
.expect("Sync pull multi-level");
|
||||||
|
|
||||||
|
let restored_file = target_dir
|
||||||
|
.join("level1")
|
||||||
|
.join("level2")
|
||||||
|
.join("level3")
|
||||||
|
.join("deep_doc.txt");
|
||||||
|
assert!(
|
||||||
|
restored_file.exists(),
|
||||||
|
"Mehrstufige Datei muss korrekt auf dem Host angelegt worden sein"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(restored_file).unwrap(), b"Deeply nested content");
|
||||||
|
|
||||||
|
// 3. Traversal-Abwehr: Bösartiger Knoten mit relativem Ausbruchsversuch
|
||||||
|
// Simuliere manipulierten Knoten im Tresor
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
// Erstelle Knoten mit manipuliertem Namen '../evil.txt'
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
||||||
|
VALUES (9999, 1, '../evil.txt', 0, 10, 100, 100, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let malicious_target = temp_root.join("malicious_target");
|
||||||
|
fs::create_dir_all(&malicious_target).unwrap();
|
||||||
|
|
||||||
|
let pull_res = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
malicious_target.to_str().unwrap(),
|
||||||
|
&pull_opts,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pull_res.is_err(),
|
||||||
|
"Sync Pull muss bei manipuliertem Traversal-Pfad abbrechen"
|
||||||
|
);
|
||||||
|
let err_msg = pull_res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Path traversal")
|
||||||
|
|| err_msg.contains("Ungültiger Dateiname")
|
||||||
|
|| err_msg.contains("unzulässige Trennzeichen"),
|
||||||
|
"Fehlermeldung muss Traversal-Erkennung ausweisen: {}",
|
||||||
|
err_msg
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!temp_root.join("evil.txt").exists(),
|
||||||
|
"Es darf niemals eine Datei außerhalb des Zielverzeichnisses erzeugt werden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aufräumen
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s03_symlink_skipping_and_cycle_protection() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s03_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let sub_dir = source_dir.join("sub");
|
||||||
|
|
||||||
|
fs::create_dir_all(&sub_dir).unwrap();
|
||||||
|
fs::write(source_dir.join("regular.txt"), b"Regular content").unwrap();
|
||||||
|
|
||||||
|
// Erzeuge eine rekursive Verknüpfung (Junction unter Windows / Symlink unter Unix)
|
||||||
|
let loop_path = sub_dir.join("cycle");
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let status = std::process::Command::new("powershell")
|
||||||
|
.args([
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"New-Item -ItemType Junction -Path '{}' -Target '{}'",
|
||||||
|
loop_path.display(),
|
||||||
|
source_dir.display()
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
if status.is_err() || !status.unwrap().success() {
|
||||||
|
eprintln!("Junction creation skipped (not supported in current environment)");
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Err(e) = std::os::unix::fs::symlink(&source_dir, &loop_path) {
|
||||||
|
eprintln!("Symlink creation skipped: {e}");
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Push;
|
||||||
|
opts.quiet = true;
|
||||||
|
|
||||||
|
// Ohne S-03 Fix würde der Sync endlos in die Schleife laufen und mit Stack-Overflow abstürzen.
|
||||||
|
// Mit Fix wird der Symlink übersprungen und gezählt.
|
||||||
|
let stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/SafePush",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.expect("Sync push must succeed despite symlink cycle");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stats.files_transferred, 1,
|
||||||
|
"Nur reguläre Datei darf übertragen werden"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stats.files_skipped_symlinks >= 1,
|
||||||
|
"Symlink/Junction muss erkannt und in files_skipped_symlinks gezählt werden"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aufräumen: Unter Windows Junction vor dem remove_dir_all entfernen
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new("powershell")
|
||||||
|
.args([
|
||||||
|
"-Command",
|
||||||
|
&format!("[System.IO.Directory]::Delete('{}')", loop_path.display()),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s04_toctou_file_size_uses_bytes_written() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s04_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test.sanctum");
|
||||||
|
let file_path = temp_root.join("race_file.bin");
|
||||||
|
|
||||||
|
fs::create_dir_all(&temp_root).unwrap();
|
||||||
|
let initial_data = vec![0xABu8; 12345];
|
||||||
|
fs::write(&file_path, &initial_data).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Sync file to vault
|
||||||
|
let res = sanctum::sync::sync_single_file_to_vault(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
&file_path,
|
||||||
|
1,
|
||||||
|
"race_file.bin",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("Sync single file to vault");
|
||||||
|
|
||||||
|
match res {
|
||||||
|
sanctum::sync::FileTransferResult::Transferred { size } => {
|
||||||
|
assert_eq!(size, 12345);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected FileTransferResult::Transferred"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let node = db
|
||||||
|
.resolve_path_in_vault("/race_file.bin", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.expect("Node must exist");
|
||||||
|
assert_eq!(
|
||||||
|
node.size, 12345,
|
||||||
|
"Knotengröße muss exakt bytes_written entsprechen"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s05_zero_byte_file_truncate_removes_all_chunks() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s05_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test.sanctum");
|
||||||
|
let file_path = temp_root.join("truncate_file.bin");
|
||||||
|
|
||||||
|
fs::create_dir_all(&temp_root).unwrap();
|
||||||
|
// 1. Zuerst große Datei mit 2.5 Chunks (> 2 MB) anlegen
|
||||||
|
let large_payload = vec![0xEEu8; 2_500_000];
|
||||||
|
fs::write(&file_path, &large_payload).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Initialer Push
|
||||||
|
sanctum::sync::sync_single_file_to_vault(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
&file_path,
|
||||||
|
1,
|
||||||
|
"truncate_file.bin",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("Initial push of large file");
|
||||||
|
|
||||||
|
let node = db
|
||||||
|
.resolve_path_in_vault("/truncate_file.bin", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.expect("Node must exist");
|
||||||
|
assert_eq!(node.size, 2_500_000);
|
||||||
|
|
||||||
|
// Prüfe, dass Chunks existieren (3 Chunks: 0, 1, 2)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM chunks WHERE node_id = ?1",
|
||||||
|
rusqlite::params![node.id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 3, "Datei muss 3 Chunks besitzen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Jetzt: Lokale Datei durch 0-Byte Datei ersetzen (Truncate auf 0 Bytes)
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100)); // Timestamp ändern
|
||||||
|
fs::write(&file_path, b"").unwrap();
|
||||||
|
|
||||||
|
let res = sanctum::sync::sync_single_file_to_vault(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
&file_path,
|
||||||
|
1,
|
||||||
|
"truncate_file.bin",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("Push of 0-byte file");
|
||||||
|
|
||||||
|
match res {
|
||||||
|
sanctum::sync::FileTransferResult::Transferred { size } => {
|
||||||
|
assert_eq!(size, 0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Transferred {{ size: 0 }}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verifiziere: In SQLite darf KEIN EINZIGER Chunk mehr für diesen Knoten existieren!
|
||||||
|
// (Ohne den S-05 Fix blieb Chunk 0 fälschlicherweise in der Tabelle zurück)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM chunks WHERE node_id = ?1",
|
||||||
|
rusqlite::params![node.id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
count, 0,
|
||||||
|
"Bei 0-Byte Datei müssen ALLE Chunks (inkl. Chunk 0) gelöscht sein (S-05)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated_node = db
|
||||||
|
.resolve_path_in_vault("/truncate_file.bin", 0, &dek)
|
||||||
|
.unwrap()
|
||||||
|
.expect("Node must exist");
|
||||||
|
assert_eq!(updated_node.size, 0);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s06_advisory_lock_blocks_sync_and_force_overrides() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s06_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("test.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::write(source_dir.join("test_lock.txt"), b"Locked content").unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// 1. Initial: Kein Lock aktiv
|
||||||
|
assert!(db.check_advisory_lock().unwrap().is_none());
|
||||||
|
|
||||||
|
// 2. Lock setzen (simuliert laufenden Mount)
|
||||||
|
db.acquire_advisory_lock(false)
|
||||||
|
.expect("Acquire advisory lock");
|
||||||
|
let active_lock = db.check_advisory_lock().unwrap();
|
||||||
|
assert!(active_lock.is_some());
|
||||||
|
let (pid, _host, _time) = active_lock.unwrap();
|
||||||
|
assert_eq!(pid, std::process::id());
|
||||||
|
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Push;
|
||||||
|
opts.quiet = true;
|
||||||
|
opts.force = false;
|
||||||
|
|
||||||
|
// 3. Sync ohne --force muss mit Lock-Fehler abbrechen
|
||||||
|
let sync_res = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sync_res.is_err(),
|
||||||
|
"Sync ohne --force muss bei aktivem Advisory Lock abbrechen"
|
||||||
|
);
|
||||||
|
let err_msg = sync_res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Container ist gesperrt"),
|
||||||
|
"Fehlermeldung muss Lock-Hinweis enthalten: {}",
|
||||||
|
err_msg
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Sync mit --force muss trotz Lock erfolgreich durchlaufen
|
||||||
|
opts.force = true;
|
||||||
|
let sync_force_res = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sync_force_res.is_ok(),
|
||||||
|
"Sync mit --force muss trotz aktivem Advisory Lock gelingen"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Lock freigeben
|
||||||
|
db.release_advisory_lock().expect("Release advisory lock");
|
||||||
|
assert!(db.check_advisory_lock().unwrap().is_none());
|
||||||
|
|
||||||
|
// 6. Sync ohne --force gelingt jetzt wieder
|
||||||
|
opts.force = false;
|
||||||
|
let sync_unlocked = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
assert!(sync_unlocked.is_ok());
|
||||||
|
|
||||||
|
// 7. Stale Lock Test: Lock mit nicht mehr existierender PID wird automatisch bereinigt
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
let current_host = std::env::var("COMPUTERNAME")
|
||||||
|
.or_else(|_| std::env::var("HOSTNAME"))
|
||||||
|
.unwrap_or_else(|_| "localhost".to_string());
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET lock_pid = 99999999, lock_host = ?1, lock_time = 1000 WHERE slot_id = 0",
|
||||||
|
rusqlite::params![current_host],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check_advisory_lock erkennt den toten Prozess und räumt den Lock auf
|
||||||
|
assert!(
|
||||||
|
db.check_advisory_lock().unwrap().is_none(),
|
||||||
|
"Stale Lock eines toten Prozesses muss automatisch als None bewertet werden"
|
||||||
|
);
|
||||||
|
let sync_stale = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sync_stale.is_ok(),
|
||||||
|
"Sync muss bei verwaistem Lock eines toten Prozesses ohne --force gelingen"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s07_skip_invalid_windows_filenames_push_and_pull() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s07_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("s07.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let restore_dir = temp_root.join("restore");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Erstelle valide Dateien
|
||||||
|
fs::write(source_dir.join("valid1.txt"), b"Content 1").unwrap();
|
||||||
|
fs::write(source_dir.join("valid2.txt"), b"Content 2").unwrap();
|
||||||
|
|
||||||
|
// Versuche eine Datei mit ungültigem Namen anzulegen
|
||||||
|
// Unter Windows via extended path r"\\?\"
|
||||||
|
let extended_aux = format!(r"\\?\{}\aux.txt", source_dir.display());
|
||||||
|
let created_aux = fs::write(&extended_aux, b"reserved aux file").is_ok();
|
||||||
|
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Push;
|
||||||
|
opts.quiet = true;
|
||||||
|
|
||||||
|
// 1. Push: Darf bei Vorhandensein von aux.txt nicht abbrechen
|
||||||
|
let push_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.expect("Push sync must not abort when encountering invalid filenames");
|
||||||
|
|
||||||
|
assert_eq!(push_stats.files_transferred, 2);
|
||||||
|
if created_aux {
|
||||||
|
assert!(push_stats.files_skipped_invalid >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Jetzt fügen wir manuell einen ungültigen Knoten in die Datenbank ein
|
||||||
|
// (z. B. von Linux oder externem Container importiert: "aux.txt")
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
let root_id = Database::get_root_node_id_for_vault(0);
|
||||||
|
let now = 123456789;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
||||||
|
VALUES (?1, ?2, 0, 0, ?3, ?4)",
|
||||||
|
rusqlite::params![root_id, "aux.txt", now, now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pull: Darf bei unzulässigem Knotennamen im Tresor nicht abbrechen
|
||||||
|
opts.direction = SyncDirection::Pull;
|
||||||
|
let pull_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
restore_dir.to_str().unwrap(),
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.expect("Pull sync must not abort when encountering invalid filenames in vault");
|
||||||
|
|
||||||
|
// Valide Dateien müssen wiederhergestellt worden sein
|
||||||
|
assert!(restore_dir.join("valid1.txt").exists());
|
||||||
|
assert!(restore_dir.join("valid2.txt").exists());
|
||||||
|
assert_eq!(pull_stats.files_transferred, 2);
|
||||||
|
assert_eq!(pull_stats.files_skipped_invalid, 1);
|
||||||
|
|
||||||
|
if created_aux {
|
||||||
|
let _ = fs::remove_file(&extended_aux);
|
||||||
|
}
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s08_refined_glob_and_directory_exclusions() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s08_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("s08.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let restore_dir = temp_root.join("restore");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::create_dir_all(source_dir.join("build")).unwrap();
|
||||||
|
fs::create_dir_all(source_dir.join("logs")).unwrap();
|
||||||
|
fs::create_dir_all(source_dir.join("node_modules").join("dep")).unwrap();
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Dateien anlegen
|
||||||
|
fs::write(source_dir.join("main.rs"), b"fn main() {}").unwrap();
|
||||||
|
fs::write(source_dir.join("temp_1.tmp"), b"junk 1").unwrap();
|
||||||
|
fs::write(source_dir.join("build").join("output.bin"), b"binary").unwrap();
|
||||||
|
fs::write(source_dir.join("build").join("notes.txt"), b"keep notes").unwrap();
|
||||||
|
fs::write(source_dir.join("logs").join("sync.log"), b"log data").unwrap();
|
||||||
|
fs::write(
|
||||||
|
source_dir.join("node_modules").join("dep").join("lib.js"),
|
||||||
|
b"module code",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut opts = SyncOptions::default();
|
||||||
|
opts.direction = SyncDirection::Push;
|
||||||
|
opts.quiet = true;
|
||||||
|
opts.exclude_patterns = vec![
|
||||||
|
"*.tmp".to_string(),
|
||||||
|
"build/*.bin".to_string(),
|
||||||
|
"logs/".to_string(),
|
||||||
|
"node_modules".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. Push
|
||||||
|
let push_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.expect("Push sync with refined glob exclusions");
|
||||||
|
|
||||||
|
// Übertragen werden dürfen nur: main.rs und build/notes.txt (2 Dateien)
|
||||||
|
// Ausgeschlossen: temp_1.tmp (*.tmp), build/output.bin (build/*.bin), logs/sync.log (logs/), node_modules/dep/lib.js (node_modules)
|
||||||
|
assert_eq!(push_stats.files_transferred, 2);
|
||||||
|
|
||||||
|
// 2. Pull
|
||||||
|
opts.direction = SyncDirection::Pull;
|
||||||
|
let pull_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
restore_dir.to_str().unwrap(),
|
||||||
|
&opts,
|
||||||
|
)
|
||||||
|
.expect("Pull sync with refined glob exclusions");
|
||||||
|
|
||||||
|
assert_eq!(pull_stats.files_transferred, 2);
|
||||||
|
assert!(restore_dir.join("main.rs").exists());
|
||||||
|
assert!(restore_dir.join("build").join("notes.txt").exists());
|
||||||
|
assert!(!restore_dir.join("temp_1.tmp").exists());
|
||||||
|
assert!(!restore_dir.join("build").join("output.bin").exists());
|
||||||
|
assert!(!restore_dir.join("logs").join("sync.log").exists());
|
||||||
|
assert!(!restore_dir.join("node_modules").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s09_pull_backup_and_update_conflict_safety() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s09_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("s09.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let local_dest_dir = temp_root.join("dest");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::create_dir_all(&local_dest_dir).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Datei in den Tresor laden
|
||||||
|
fs::write(source_dir.join("document.txt"), b"Vault Version 2.0").unwrap();
|
||||||
|
fs::write(source_dir.join("notes.txt"), b"Older Vault Notes").unwrap();
|
||||||
|
|
||||||
|
let mut push_opts = SyncOptions::default();
|
||||||
|
push_opts.direction = SyncDirection::Push;
|
||||||
|
push_opts.quiet = true;
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&push_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Test Backup-Erstellung (--backup):
|
||||||
|
// Lokale Datei anlegen mit eigenem Inhalt
|
||||||
|
let local_doc = local_dest_dir.join("document.txt");
|
||||||
|
fs::write(&local_doc, b"Local Version 1.0 (Must be backed up)").unwrap();
|
||||||
|
|
||||||
|
let mut pull_opts = SyncOptions::default();
|
||||||
|
pull_opts.direction = SyncDirection::Pull;
|
||||||
|
pull_opts.quiet = true;
|
||||||
|
pull_opts.backup = true;
|
||||||
|
|
||||||
|
let pull_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&pull_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pull_stats.files_backed_up, 1);
|
||||||
|
assert_eq!(fs::read(&local_doc).unwrap(), b"Vault Version 2.0");
|
||||||
|
|
||||||
|
let backup_doc = local_dest_dir.join("document.txt.bak");
|
||||||
|
assert!(
|
||||||
|
backup_doc.exists(),
|
||||||
|
"Backup-Datei .bak muss angelegt worden sein"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&backup_doc).unwrap(),
|
||||||
|
b"Local Version 1.0 (Must be backed up)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Test Konfliktschutz (--update):
|
||||||
|
// Erzeuge lokale notes.txt mit neuerem Timestamp
|
||||||
|
let local_notes = local_dest_dir.join("notes.txt");
|
||||||
|
fs::write(&local_notes, b"Newer Local Notes").unwrap();
|
||||||
|
let future_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000);
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(&local_notes)
|
||||||
|
.unwrap()
|
||||||
|
.set_times(std::fs::FileTimes::new().set_modified(future_time))
|
||||||
|
.expect("Set modified time on local_notes");
|
||||||
|
|
||||||
|
let mut update_opts = SyncOptions::default();
|
||||||
|
update_opts.direction = SyncDirection::Pull;
|
||||||
|
update_opts.quiet = true;
|
||||||
|
update_opts.update = true;
|
||||||
|
|
||||||
|
let update_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&update_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// notes.txt darf NICHT überschrieben worden sein
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&local_notes).unwrap(),
|
||||||
|
b"Newer Local Notes",
|
||||||
|
"Neuere lokale Datei darf mit --update nicht überschrieben werden"
|
||||||
|
);
|
||||||
|
assert!(update_stats.files_skipped >= 1);
|
||||||
|
|
||||||
|
// Mit --force muss sie überschrieben werden
|
||||||
|
update_opts.force = true;
|
||||||
|
let force_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&update_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&local_notes).unwrap(),
|
||||||
|
b"Older Vault Notes",
|
||||||
|
"Mit --force muss die neuere Datei dennoch überschrieben werden"
|
||||||
|
);
|
||||||
|
assert_eq!(force_stats.files_transferred, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
|
||||||
MIN_TIME_COST,
|
wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
|
use sanctum::verify::verify_container;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -677,3 +678,320 @@ fn test_container_meta_authenticate_defense_in_depth() {
|
|||||||
};
|
};
|
||||||
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_k01_metadata_tampering_detected_by_verify() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "SecretMasterPassword2026!";
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.set_active_dek(dek.clone());
|
||||||
|
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Knoten anlegen
|
||||||
|
let node = db.create_node(1, "secret_financials.xlsx", false).unwrap();
|
||||||
|
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
|
||||||
|
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
report.is_healthy(),
|
||||||
|
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
|
||||||
|
report.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
|
||||||
|
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
!tampered_report.is_healthy(),
|
||||||
|
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tampered_report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
||||||
|
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
||||||
|
tampered_report.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
|
||||||
|
[node.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
!tampered_name_report.is_healthy(),
|
||||||
|
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tampered_name_report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
||||||
|
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
||||||
|
tampered_name_report.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_k01_upgrade_format_v2_to_v3() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf =
|
||||||
|
temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "UpgradeMasterPassword2026!";
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
||||||
|
|
||||||
|
// Erzeuge bewusst einen V2-Container ohne metadata_mac
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER NOT NULL 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
|
||||||
|
);
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_dir INTEGER NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
modified_at INTEGER NOT NULL,
|
||||||
|
is_carrier INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE chunks (
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
chunk_index INTEGER NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
tag BLOB NOT NULL,
|
||||||
|
ciphertext BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (node_id, chunk_index)
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let params_json = serde_json::to_string(&kdf_params).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, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
sanctum::crypto::MAGIC_BYTES.as_slice(),
|
||||||
|
salt.as_slice(),
|
||||||
|
params_json,
|
||||||
|
wrapped_dek,
|
||||||
|
nonce.as_slice(),
|
||||||
|
tag.as_slice(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
|
||||||
|
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let meta_before = db.read_meta().unwrap();
|
||||||
|
assert_eq!(meta_before.version, 2);
|
||||||
|
|
||||||
|
// Upgrade auf Format V3 durchführen
|
||||||
|
db.upgrade_to_v3(&dek).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
let meta_after = db.read_meta().unwrap();
|
||||||
|
assert_eq!(meta_after.version, 3);
|
||||||
|
|
||||||
|
// V3-Container muss nach dem Upgrade integer und gesund sein
|
||||||
|
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
report.is_healthy(),
|
||||||
|
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
|
||||||
|
report.errors
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_k02_chunk_replay_detected_by_vfs_and_crypto() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path = temp_dir.join(format!(
|
||||||
|
"k02_replay_test_{}.sanctum",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
|
||||||
|
let password = "TestPasswordK02!";
|
||||||
|
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(password, &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.set_active_dek(dek.clone());
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Datei im Format V3 anlegen
|
||||||
|
let file = db.create_node(1, "replay_target.txt", false).unwrap();
|
||||||
|
let initial_data = b"State 1: Initial secret content in chunk 0.";
|
||||||
|
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||||
|
let (ct1, nonce1, tag1) = encrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
initial_data,
|
||||||
|
sanctum::crypto::FORMAT_VERSION_V3,
|
||||||
|
gen1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.write_chunk_and_update_size(
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
gen1,
|
||||||
|
&nonce1,
|
||||||
|
&tag1,
|
||||||
|
&ct1,
|
||||||
|
initial_data.len() as u64,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// Ciphertext-Zeile von Zustand 1 sichern (Nonce, Tag, Ciphertext)
|
||||||
|
let saved_chunk1 = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||||
|
assert_eq!(saved_chunk1.generation, gen1);
|
||||||
|
|
||||||
|
// 2. Chunk mit neuem Inhalt überschreiben (Zustand 2)
|
||||||
|
let updated_data = b"State 2: Updated overwritten content in chunk 0.";
|
||||||
|
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||||
|
assert!(gen2 > gen1, "Generation muss monoton steigen");
|
||||||
|
let (ct2, nonce2, tag2) = encrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
updated_data,
|
||||||
|
sanctum::crypto::FORMAT_VERSION_V3,
|
||||||
|
gen2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.write_chunk_and_update_size(
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
gen2,
|
||||||
|
&nonce2,
|
||||||
|
&tag2,
|
||||||
|
&ct2,
|
||||||
|
updated_data.len() as u64,
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
let current_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||||
|
assert_eq!(current_chunk.generation, gen2);
|
||||||
|
|
||||||
|
// 3. Replay-Angriff: Angreifer spielt alte Ciphertext-Zeile von Zustand 1 zurück in die SQLite-Tabelle
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = 0",
|
||||||
|
rusqlite::params![
|
||||||
|
saved_chunk1.nonce.as_slice(),
|
||||||
|
saved_chunk1.tag.as_slice(),
|
||||||
|
saved_chunk1.ciphertext,
|
||||||
|
file.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entschlüsselungsversuch muss fehlschlagen (AEAD Auth-Fehler wegen AAD-Generationsabweichung)
|
||||||
|
let replayed_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||||
|
let decrypt_res = decrypt_chunk(
|
||||||
|
&dek,
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
&replayed_chunk.ciphertext,
|
||||||
|
&replayed_chunk.nonce,
|
||||||
|
&replayed_chunk.tag,
|
||||||
|
sanctum::crypto::FORMAT_VERSION_V3,
|
||||||
|
replayed_chunk.generation,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decrypt_res.is_err(),
|
||||||
|
"K-02: Replay von altem Ciphertext in aktuellem Chunk-Slot muss durch AEAD AAD-Mismatch abgewiesen werden!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify muss Replay/Manipulierte Chunks erkennen
|
||||||
|
let verify_res = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(
|
||||||
|
!verify_res.is_healthy() || verify_res.corrupted_chunks > 0,
|
||||||
|
"Verify muss Replay/Manipulierte Chunks erkennen"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use sanctum::upgrade::{
|
use sanctum::upgrade::{
|
||||||
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
run_upgrade, validate_trusted_comment_version, verify_minisign_signature,
|
||||||
|
verify_release_manifest_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
||||||
|
SANCTUM_RELEASE_PUBKEY_BACKUP,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -75,3 +77,117 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
|
|||||||
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
|
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_u01_reject_legacy_unhashed_signature() {
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Gültige pre-hashed Signatur muss weiterhin erfolgreich geprüft werden
|
||||||
|
assert!(verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY).is_ok());
|
||||||
|
|
||||||
|
// Modifiziere die Signatur, um eine Legacy-Signatur (Ed statt ED) zu simulieren
|
||||||
|
// In Base64 entspricht 'ED' (0x45, 0x44) dem Präfix 'RU'
|
||||||
|
// 'Ed' (0x45, 0x64) entspricht in Base64 dem Präfix 'RW'
|
||||||
|
let lines: Vec<&str> = sig_text.lines().collect();
|
||||||
|
let mut sig_line = lines[1].to_string();
|
||||||
|
assert!(sig_line.starts_with("RU"));
|
||||||
|
sig_line.replace_range(0..2, "RW");
|
||||||
|
|
||||||
|
let legacy_sig_text = format!("{}\n{}\n{}\n{}\n", lines[0], sig_line, lines[2], lines[3]);
|
||||||
|
|
||||||
|
let res = verify_minisign_signature(payload, &legacy_sig_text, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"U-01: Legacy-Signaturen (ohne Pre-Hashing) müssen strikt abgewiesen werden"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_u02_validate_trusted_comment_version_matching() {
|
||||||
|
// Standard Minisign format with -t "version:0.8.0"
|
||||||
|
let tc1 = "timestamp:1789750897\tversion:0.8.0\tfile:SHA256SUMS.txt\thashed";
|
||||||
|
assert!(validate_trusted_comment_version(tc1, "0.8.0").is_ok());
|
||||||
|
assert!(validate_trusted_comment_version(tc1, "v0.8.0").is_ok());
|
||||||
|
|
||||||
|
// Alternative tags like tag:v0.8.0 or release:0.8.0
|
||||||
|
let tc2 = "release:0.8.0";
|
||||||
|
assert!(validate_trusted_comment_version(tc2, "0.8.0").is_ok());
|
||||||
|
let tc3 = "tag:v0.8.0";
|
||||||
|
assert!(validate_trusted_comment_version(tc3, "0.8.0").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_u02_reject_mismatched_release_tag_replay() {
|
||||||
|
// An attacker replays a valid signature from v0.7.0 for an update claiming to be v0.8.0
|
||||||
|
let tc_old = "timestamp:1789750897\tversion:0.7.0\tfile:SHA256SUMS.txt\thashed";
|
||||||
|
let res = validate_trusted_comment_version(tc_old, "v0.8.0");
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"U-02: Ältere oder abweichende Version im Trusted Comment muss abgewiesen werden"
|
||||||
|
);
|
||||||
|
let err_msg = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("U-02") && err_msg.contains("Replay-Angriff"),
|
||||||
|
"Fehlermeldung muss auf Replay-Angriff und U-02 hinweisen: {err_msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty or completely missing version token
|
||||||
|
let tc_no_ver = "timestamp:1789750897\tfile:SHA256SUMS.txt\thashed";
|
||||||
|
let res2 = validate_trusted_comment_version(tc_no_ver, "0.8.0");
|
||||||
|
assert!(res2.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_u03_verify_signature_with_backup_key_fallback() {
|
||||||
|
let payload = b"Hello Sanctum Release 0.8.0 Backup Key Testing\n";
|
||||||
|
let backup_sig = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
|
||||||
|
trusted comment: version:0.8.0\n\
|
||||||
|
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
|
||||||
|
|
||||||
|
// 1. Direct verification against primary key MUST fail (key rotation scenario)
|
||||||
|
let prim_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(
|
||||||
|
prim_res.is_err(),
|
||||||
|
"Backup-Signatur darf nicht gegen den Primärschlüssel verifizieren"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Direct verification against backup key MUST succeed
|
||||||
|
let back_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY_BACKUP);
|
||||||
|
assert!(
|
||||||
|
back_res.is_ok(),
|
||||||
|
"Backup-Signatur muss gegen SANCTUM_RELEASE_PUBKEY_BACKUP gültig sein: {:?}",
|
||||||
|
back_res.err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Fallback verification via verify_release_manifest_signature MUST succeed transparently
|
||||||
|
let fallback_res = verify_release_manifest_signature(payload, backup_sig);
|
||||||
|
assert!(
|
||||||
|
fallback_res.is_ok(),
|
||||||
|
"verify_release_manifest_signature muss via Backup-Schlüssel erfolgreich sein"
|
||||||
|
);
|
||||||
|
assert_eq!(fallback_res.unwrap(), "version:0.8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_u03_verify_signature_rejects_untrusted_third_party_key() {
|
||||||
|
let fake_sig = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
|
||||||
|
trusted comment: version:0.8.0\n\
|
||||||
|
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
|
||||||
|
|
||||||
|
// Tampered payload with neither primary nor backup key matching
|
||||||
|
let tampered = b"Tampered data not signed by either key";
|
||||||
|
let res = verify_release_manifest_signature(tampered, fake_sig);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Weder Primär- noch Backup-Schlüssel dürfen manipulierte Nutzdaten akzeptieren"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user