Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f731f57d | ||
|
|
728ac008ae | ||
|
|
8ff426d1b0 | ||
|
|
efddc2c477 | ||
|
|
dd6b6ff94d | ||
|
|
c21d63ad28 | ||
|
|
c606fa8132 | ||
|
|
8455dc02db | ||
|
|
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 |
+114
@@ -5,6 +5,120 @@ 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.1] - 2026-09-19
|
||||||
|
|
||||||
|
### Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
|
||||||
|
Dieses Release behebt sechs Findings aus der dritten Audit-Runde in `carrier.rs` (steganografisches Dateisystem des Hidden Vault) und `storage.rs`, schließt alle verbleibenden Punkte und erreicht 39/39 (100%) behobene Audit-Findings.
|
||||||
|
|
||||||
|
#### Steganografischer Hidden Vault & Carrier-Dateisystem (C-02, C-03, C-04)
|
||||||
|
- **C-02 (HOCH): Rollierendes Dual-Block-Manifest & Failover-Recovery**:
|
||||||
|
- Beseitigung des Single Point of Failure: Das Dateisystem-Manifest wird nun abwechselnd auf Block 0 und Block 1 geschrieben (`manifest_generation: u64`).
|
||||||
|
- Beim Mounten liest Sanctum beide Blöcke, wählt die neuere Generation und stellt den Tresor selbst bei Beschädigung (z. B. Bitrot) eines Blocks transparent wieder her.
|
||||||
|
- Vollständige Abwärtskompatibilität: Block 1 wird für bestehende Container automatisch reserviert.
|
||||||
|
- **C-03 (MITTEL): Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking**:
|
||||||
|
- Metadatenänderungen (`create_dir`, `create_file`, `remove_file`, `remove_dir`, `rename`, `copy`, `drop`) markieren das Manifest als `dirty`, anstatt jedes Mal synchron 1 MB per AES-256-GCM neu zu verschlüsseln.
|
||||||
|
- Synchrone Sicherung erfolgt gebündelt bei Datei-`flush()`, unmount/drop oder nach 50 Operationen.
|
||||||
|
- Gewährleistet massive Performancesteigerung bei Massenoperationen im Hidden Vault.
|
||||||
|
- **C-04 (NIEDRIG): Kapazitätsgrenze dokumentiert & Warnung bei Füllstand**:
|
||||||
|
- Dokumentation der Obergrenze (~7.000 Dateien/Verzeichnisse pro 1-MB-Manifest-Block) in `README.md` und `QUICKSTART.md`.
|
||||||
|
- Automatische Warnung beim Mounten und Speichern, wenn das Manifest mehr als 80% Füllstand (~838 KB) erreicht.
|
||||||
|
|
||||||
|
#### Storage-Hygiene & Fehlerbehandlung (ST-01, ST-02, ST-03)
|
||||||
|
- **ST-01 (MITTEL): Fail-Closed bei Entschlüsselungsfehlern von Knotennamen**:
|
||||||
|
- Beseitigung des Hex-Ciphertext-Leaks (`unwrap_or(enc_name)`): Bei Entschlüsselungsfehlern (z. B. Bitrot oder unlesbare Knoten) werden betroffene Einträge in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` übersprungen und geloggt (`warn!`), anstatt rohe Hex-Strings auszuliefern.
|
||||||
|
- **ST-02 (NIEDRIG): Fehleraggregation bei rekursivem Löschen**:
|
||||||
|
- `delete_node` verschluckt Fehler bei Kindknoten nicht mehr (`let _ =`).
|
||||||
|
- Fehler werden gesammelt, protokolliert (`error!`) und führen zum Abbruch mit detaillierter Zusammenfassung, wodurch verwaiste Knoten (Orphans) verhindert werden.
|
||||||
|
- **ST-03 (NIEDRIG): Atomare SQLite-Transaktion für rekursives Löschen**:
|
||||||
|
- Das gesamte rekursive Löschen eines Baumes (inklusive kryptografischem Chunk-Shredding) wird nun in einer einzigen atomaren SQLite-Transaktion ausgeführt.
|
||||||
|
- Schlägt das Löschen eines Teilbaums fehl, wird ein automatischer Rollback ausgeführt; Teilbaumlöschungen und inkonsistente Zustände sind ausgeschlossen.
|
||||||
|
|
||||||
|
## [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.1"
|
||||||
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.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -37,6 +37,7 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
|||||||
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
|
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
|
||||||
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
|
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
|
||||||
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
|
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
|
||||||
|
* **Kapazitätsgrenze (C-04)**: Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse im Hidden Vault (~1 MB Manifest-Block). Sanctum warnt beim Mounten automatisch bei über 80% Füllstand.
|
||||||
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -73,7 +74,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).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
|||||||
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
||||||
- **Dual-Vault (Multi-Slot & Carrier)**:
|
- **Dual-Vault (Multi-Slot & Carrier)**:
|
||||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
||||||
|
*Kapazitätsgrenze des Hidden Vault (C-04)*: Das Dateisystem des Hidden Vaults verwaltet seine Inodes in einem dual-block rollierenden Manifest (~1 MB pro Block). Daraus ergibt sich eine empfohlene Obergrenze von ca. 7.000 Dateien und Verzeichnissen im Hidden Vault. Bei Überschreiten von 80% Füllstand (~838 KB) warnt Sanctum beim Einbinden automatisch vor Erreichen der Blockgrenze.
|
||||||
- **Dateinamen-Verschlüsselung**:
|
- **Dateinamen-Verschlüsselung**:
|
||||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||||
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
||||||
@@ -58,6 +59,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 +111,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 +128,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 +199,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 +310,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,117 @@
|
|||||||
|
# Sanctum Security Audit & Remediation Log (v0.8.1)
|
||||||
|
|
||||||
|
Dieses Dokument fasst alle 39 Findings aus drei 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.1
|
||||||
|
- **Behobene Findings:** 39 / 39 (100%)
|
||||||
|
- **Test-Ergebnis:** 123 / 123 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).
|
||||||
|
- **Hidden Vault & Storage-Resilienz:** Dual-Block rollierendes Manifest (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Kapazitäts-Warnung (C-04), Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersicht aller Findings
|
||||||
|
|
||||||
|
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **C-02** | HOCH | `carrier` | Single Point of Failure beseitigt: Rollierendes Dual-Block-Manifest (Block 0 & 1, `manifest_generation`) mit transparenter Failover-Wiederherstellung | `8455dc0` | ✅ Bestanden (`test_c02_dual_block_rolling_manifest_redundancy_and_recovery`) |
|
||||||
|
| **C-03** | MITTEL | `carrier`, `vfs`, `mount` | Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking; synchrone Sicherung bei File-Flush und Unmount | `c606fa8` | ✅ Bestanden (`test_c03_manifest_dirty_decoupling_and_unmount_flush`) |
|
||||||
|
| **C-04** | NIEDRIG | `carrier`, `mount`, `doc` | Dokumentation der Hidden-Vault-Kapazität (~7.000 Inodes) & automatische 80%-Füllstandswarnung beim Mounten | `c21d63a` | ✅ Bestanden (`test_c04_manifest_capacity_limit_and_warning`) |
|
||||||
|
| **ST-01** | MITTEL | `storage` | Fail-Closed bei Knotennamen-Entschlüsselung; Beseitigung von Hex-Ciphertext-Leaks bei Bitrot/Korruption | `dd6b6ff` | ✅ Bestanden (`test_st01_fail_closed_name_decryption_no_hex_leak`) |
|
||||||
|
| **ST-02** | NIEDRIG | `storage` | Fehleraggregation beim rekursiven Löschen in `delete_node` ohne stilles Verschlucken | `efddc2c` | ✅ Bestanden (`test_st02_recursive_delete_error_propagation_not_swallowed`) |
|
||||||
|
| **ST-03** | NIEDRIG | `storage` | Atomare SQLite-Transaktion (`tx.commit()` / Rollback) beim rekursiven Löschen in `delete_node` | `8ff426d` | ✅ Bestanden (`test_st03_recursive_delete_transaction_rollback`) |
|
||||||
|
| **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.
|
||||||
|
### 4. Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
|
||||||
|
- **Rollierendes Dual-Block-Manifest (C-02):**
|
||||||
|
- Das Carrier-Manifest wird abwechselnd auf Block 0 und Block 1 geschrieben, geschützt durch ein monotones Generation-Attribut (`manifest_generation: u64`).
|
||||||
|
- Beim Mounten liest Sanctum beide Blöcke. Ist ein Block korrumpiert (z. B. Bitrot oder unvollständiges Schreiben), stellt Sanctum den Hidden Vault transparent aus der intakten Kopie wieder her.
|
||||||
|
- **Entkopplung der Manifest-Neuverschlüsselung (C-03):**
|
||||||
|
- Vor v0.8.1 wurde bei jeder Metadatenänderung (Dateierstellung, Ordnererstellung, Löschen, Umbenennen, Drop) das gesamte 1-MB-Manifest synchron per AES-256-GCM neu verschlüsselt.
|
||||||
|
- v0.8.1 führt Dirty-Tracking ein: Änderungen markieren das Manifest als `dirty`. Die tatsächliche Verschlüsselung erfolgt gebündelt bei Datei-`flush()`, Unmount/Drop oder nach 50 Operationen.
|
||||||
|
- **Kapazitätsgrenze & Warnschwelle (C-04):**
|
||||||
|
- Durch den 1-MB-Block für das Manifest ergibt sich eine empfohlene Obergrenze von ~7.000 Inodes im Hidden Vault.
|
||||||
|
- Ab 80% Füllstand (~838 KB) warnt Sanctum beim Mounten und Speichern unübersehbar.
|
||||||
|
- **Fail-Closed bei Knotennamen (ST-01):**
|
||||||
|
- Bei Fehlschlagen der Entschlüsselung von Knotennamen werden keine Hex-Ciphertext-Fragmente mehr exponiert (`unwrap_or(enc_name)` eliminiert).
|
||||||
|
- Unlesbare Knoten werden mit `warn!` geloggt und in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` fail-closed übersprungen.
|
||||||
|
- **Fehleraggregation & Transaktions-Rollback bei rekursivem Löschen (ST-02 & ST-03):**
|
||||||
|
- Fehler beim Löschen von Kindknoten werden nicht mehr ignoriert (`let _ =` eliminiert), sondern aggregiert und protokolliert.
|
||||||
|
- Das gesamte Löschen eines Baumes samt kryptografischem Chunk-Shredding läuft in einer atomaren SQLite-Transaktion. Schlägt ein Knoten fehl, wird der gesamte Vorgang per Rollback zurückgesetzt; verwaiste Knoten (Orphans) sind ausgeschlossen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikationsnachweis
|
||||||
|
|
||||||
|
Alle 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.1",
|
||||||
"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.1/sanctum-v0.8.1-windows-x86_64.zip",
|
||||||
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
|
"hash": "11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c",
|
||||||
"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.1
|
||||||
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.1
|
||||||
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.1/sanctum-v0.8.1-windows-x86_64.zip
|
||||||
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
|
InstallerSha256: 11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c
|
||||||
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 {
|
||||||
|
|||||||
+430
-32
@@ -18,21 +18,25 @@ use futures_util::stream;
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, warn};
|
use tracing::{debug, error, warn};
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
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;
|
||||||
|
pub const CARRIER_MANIFEST_CAPACITY: usize = CHUNK_SIZE - 64; // 1,048,512 Bytes
|
||||||
|
pub const CARRIER_MANIFEST_WARN_THRESHOLD: usize = CARRIER_MANIFEST_CAPACITY * 8 / 10; // 838,809 Bytes (80%, C-04)
|
||||||
|
|
||||||
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0).
|
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CarrierManifest {
|
pub struct CarrierManifest {
|
||||||
pub magic: [u8; 8],
|
pub magic: [u8; 8],
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub manifest_generation: u64,
|
||||||
pub total_blocks: u32,
|
pub total_blocks: u32,
|
||||||
pub free_blocks: Vec<u32>,
|
pub free_blocks: Vec<u32>,
|
||||||
pub next_inode_id: i64,
|
pub next_inode_id: i64,
|
||||||
@@ -62,12 +66,13 @@ impl CarrierManifest {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Block 0 ist für das Manifest reserviert. Nutzblöcke sind 1..total_blocks-1.
|
// Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1.
|
||||||
let free_blocks = (1..total_blocks).collect();
|
let free_blocks = (2..total_blocks).collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
magic: *CARRIER_MAGIC,
|
magic: *CARRIER_MAGIC,
|
||||||
version: CARRIER_VERSION,
|
version: CARRIER_VERSION,
|
||||||
|
manifest_generation: 0,
|
||||||
total_blocks,
|
total_blocks,
|
||||||
free_blocks,
|
free_blocks,
|
||||||
next_inode_id: 2,
|
next_inode_id: 2,
|
||||||
@@ -115,6 +120,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 +155,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 +174,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 +183,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 +218,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 +246,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,31 +276,92 @@ 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 manifest_dirty: bool,
|
||||||
|
pub manifest_save_count: u64,
|
||||||
|
pub dirty_ops_count: u32,
|
||||||
|
pub last_manifest_save: u64,
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markiert das Manifest als modifiziert (dirty) ohne teure synchrone Neuverschlüsselung (C-03).
|
||||||
|
/// Erst bei flush(), close(), unmount/drop oder nach Überschreiten von 50 Operationen wird
|
||||||
|
/// das vollständige 1-MB-Manifest tatsächlich neu verschlüsselt und geschrieben.
|
||||||
|
///
|
||||||
|
/// Konsistenzhinweis:
|
||||||
|
/// Bei einem abrupten Prozessabbruch gilt der zuletzt gespeicherte Zustand. Dank C-02
|
||||||
|
/// (rollierendes Manifest auf Block 0 und Block 1) ist die Integrität stets gewährleistet.
|
||||||
|
pub fn mark_dirty(&mut self) {
|
||||||
|
self.manifest_dirty = true;
|
||||||
|
self.dirty_ops_count += 1;
|
||||||
|
if self.dirty_ops_count >= 50 {
|
||||||
|
let _ = self.flush_manifest_if_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt das Manifest nur, wenn ungespeicherte Änderungen anstehen (C-03).
|
||||||
|
pub fn flush_manifest_if_dirty(&mut self) -> Result<()> {
|
||||||
|
if self.manifest_dirty {
|
||||||
|
self.save_manifest()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent (C-04).
|
||||||
|
pub fn manifest_usage(&self) -> (usize, usize, f64) {
|
||||||
|
let len = serde_json::to_vec(&self.manifest)
|
||||||
|
.map(|v| v.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0;
|
||||||
|
(len, CARRIER_MANIFEST_CAPACITY, pct)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save_manifest(&mut self) -> Result<()> {
|
pub fn save_manifest(&mut self) -> Result<()> {
|
||||||
|
self.manifest_save_count += 1;
|
||||||
|
self.manifest_dirty = false;
|
||||||
|
self.dirty_ops_count = 0;
|
||||||
|
self.last_manifest_save = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.manifest.manifest_generation += 1;
|
||||||
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() > CARRIER_MANIFEST_CAPACITY {
|
||||||
bail!(
|
bail!(
|
||||||
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes).",
|
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.",
|
||||||
manifest_bytes.len(),
|
manifest_bytes.len(),
|
||||||
crate::crypto::CHUNK_SIZE - 64
|
CARRIER_MANIFEST_CAPACITY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if manifest_bytes.len() > 800_000 {
|
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||||
|
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
|
||||||
warn!(
|
warn!(
|
||||||
"Carrier-Manifest erreicht 80% der Blockkapazität ({}/{} Bytes)",
|
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
|
||||||
|
pct,
|
||||||
manifest_bytes.len(),
|
manifest_bytes.len(),
|
||||||
crate::crypto::CHUNK_SIZE - 64
|
CARRIER_MANIFEST_CAPACITY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C-02: Rollierendes Dual-Block-Manifest.
|
||||||
|
// Gerade Generation -> Block 0, ungerade Generation -> Block 1.
|
||||||
|
let target_block = if self.manifest.manifest_generation.is_multiple_of(2) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
write_carrier_block(
|
write_carrier_block(
|
||||||
&self.db,
|
&self.db,
|
||||||
self.carrier_node_id,
|
self.carrier_node_id,
|
||||||
0,
|
target_block,
|
||||||
&self.dek_outer,
|
&self.dek_outer,
|
||||||
&self.dek_inner,
|
&self.dek_inner,
|
||||||
&manifest_bytes,
|
&manifest_bytes,
|
||||||
@@ -347,6 +423,14 @@ impl CarrierFsInner {
|
|||||||
|
|
||||||
impl Drop for CarrierFsInner {
|
impl Drop for CarrierFsInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
if self.manifest_dirty {
|
||||||
|
if let Err(e) = self.save_manifest() {
|
||||||
|
error!(
|
||||||
|
"Fehler beim finalen Speichern des Carrier-Manifests im Drop: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32);
|
crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32);
|
||||||
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
||||||
}
|
}
|
||||||
@@ -359,28 +443,103 @@ 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 redundanter Block-0/Block-1 Prüfung (C-02)
|
||||||
pub fn load(
|
/// und benutzerdefinierten Anti-Leak-Regeln (V-04).
|
||||||
|
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(
|
// C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren
|
||||||
|
let block_0_res = read_carrier_block(
|
||||||
&db,
|
&db,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
0,
|
0,
|
||||||
&dek_outer,
|
&dek_outer,
|
||||||
&dek_inner,
|
&dek_inner,
|
||||||
format_version,
|
format_version,
|
||||||
)?;
|
)
|
||||||
|
.and_then(|bytes| {
|
||||||
let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?;
|
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
|
||||||
if manifest.magic != *CARRIER_MAGIC {
|
if m.magic != *CARRIER_MAGIC {
|
||||||
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
|
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
|
||||||
}
|
}
|
||||||
|
Ok(m)
|
||||||
|
});
|
||||||
|
|
||||||
|
let block_1_res = read_carrier_block(
|
||||||
|
&db,
|
||||||
|
carrier_node_id,
|
||||||
|
1,
|
||||||
|
&dek_outer,
|
||||||
|
&dek_inner,
|
||||||
|
format_version,
|
||||||
|
)
|
||||||
|
.and_then(|bytes| {
|
||||||
|
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
|
||||||
|
if m.magic != *CARRIER_MAGIC {
|
||||||
|
bail!("Ungültige Carrier-Magic-Bytes in Block 1");
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut manifest = match (block_0_res, block_1_res) {
|
||||||
|
(Ok(m0), Ok(m1)) => {
|
||||||
|
if m1.manifest_generation > m0.manifest_generation {
|
||||||
|
debug!(
|
||||||
|
"C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
|
||||||
|
m1.manifest_generation, m0.manifest_generation
|
||||||
|
);
|
||||||
|
m1
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
|
||||||
|
m0.manifest_generation, m1.manifest_generation
|
||||||
|
);
|
||||||
|
m0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Ok(m0), Err(e1)) => {
|
||||||
|
debug!(
|
||||||
|
"C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
|
||||||
|
m0.manifest_generation, e1
|
||||||
|
);
|
||||||
|
m0
|
||||||
|
}
|
||||||
|
(Err(e0), Ok(m1)) => {
|
||||||
|
warn!(
|
||||||
|
"C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).",
|
||||||
|
e0, m1.manifest_generation
|
||||||
|
);
|
||||||
|
m1
|
||||||
|
}
|
||||||
|
(Err(e0), Err(e1)) => {
|
||||||
|
bail!(
|
||||||
|
"Kritischer Datenverlust (C-02): Weder Block 0 ({}) noch Block 1 ({}) enthalten ein gültiges Carrier-Manifest! Hidden Vault nicht wiederherstellbar.",
|
||||||
|
e0, e1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren
|
||||||
|
manifest.free_blocks.retain(|&b| b >= 2);
|
||||||
|
|
||||||
|
// C-04: Warnung bei Füllstand über 80% beim Mounten
|
||||||
|
if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) {
|
||||||
|
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||||
|
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
|
||||||
|
warn!(
|
||||||
|
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
|
||||||
|
pct,
|
||||||
|
manifest_bytes.len(),
|
||||||
|
CARRIER_MANIFEST_CAPACITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -398,7 +557,13 @@ 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,
|
||||||
|
manifest_dirty: false,
|
||||||
|
manifest_save_count: 0,
|
||||||
|
dirty_ops_count: 0,
|
||||||
|
last_manifest_save: now,
|
||||||
last_activity: Arc::new(AtomicU64::new(now)),
|
last_activity: Arc::new(AtomicU64::new(now)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,8 +572,53 @@ 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 sync_manifest(&self) -> Result<()> {
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.flush_manifest_if_dirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manifest_save_count(&self) -> u64 {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.manifest_save_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_manifest_dirty(&self) -> bool {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.manifest_dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manifest_usage(&self) -> (usize, usize, f64) {
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
inner.manifest_usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +649,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,7 +690,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
n.size = 0;
|
n.size = 0;
|
||||||
n.modified_at = now;
|
n.modified_at = now;
|
||||||
inner.manifest.inodes.insert(n.id, n.clone());
|
inner.manifest.inodes.insert(n.id, n.clone());
|
||||||
let _ = inner.save_manifest();
|
inner.mark_dirty();
|
||||||
}
|
}
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
@@ -510,7 +721,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_inode.clone());
|
inner.manifest.inodes.insert(new_id, new_inode.clone());
|
||||||
let _ = inner.save_manifest();
|
inner.mark_dirty();
|
||||||
new_inode
|
new_inode
|
||||||
} else {
|
} else {
|
||||||
return Err(FsError::NotFound);
|
return Err(FsError::NotFound);
|
||||||
@@ -544,7 +755,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 +809,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +843,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_dir);
|
inner.manifest.inodes.insert(new_id, new_dir);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -657,7 +876,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -686,7 +905,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -702,7 +921,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,11 +962,157 @@ impl DavFileSystem for CarrierFs {
|
|||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
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.mark_dirty();
|
||||||
|
|
||||||
|
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 +1285,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() {
|
||||||
@@ -1003,7 +1398,10 @@ impl DavFile for CarrierFile {
|
|||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
inner
|
||||||
|
.flush_manifest_if_dirty()
|
||||||
|
.map_err(|_| FsError::GeneralFailure)?;
|
||||||
|
|
||||||
self.meta.size = self.file_size;
|
self.meta.size = self.file_size;
|
||||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||||
@@ -1033,7 +1431,7 @@ impl Drop for CarrierFile {
|
|||||||
inode.blocks = self.blocks.clone();
|
inode.blocks = self.blocks.clone();
|
||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
let _ = inner.save_manifest();
|
inner.mark_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-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,22 +184,48 @@ 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 fs_clone = fs.clone();
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
.locksystem(FakeLs::new())
|
.locksystem(FakeLs::new())
|
||||||
@@ -255,6 +283,7 @@ pub async fn mount_container(
|
|||||||
|
|
||||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||||
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
|
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
|
||||||
|
let _ = fs_clone.sync_carrier_manifest();
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
@@ -372,16 +401,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 {
|
||||||
@@ -401,6 +422,22 @@ pub async fn mount_container(
|
|||||||
if enable_tray {
|
if enable_tray {
|
||||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
||||||
}
|
}
|
||||||
|
if vault_id == 1 {
|
||||||
|
if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() {
|
||||||
|
if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||||
|
println!(
|
||||||
|
" {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
pct,
|
||||||
|
len,
|
||||||
|
cap
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Empfohlene Obergrenze beachten: ca. 7.000 Dateien/Verzeichnisse im Hidden Vault."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
println!(
|
println!(
|
||||||
@@ -522,6 +559,7 @@ pub async fn mount_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stealth {
|
if stealth {
|
||||||
|
let _ = fs_clone.sync_carrier_manifest();
|
||||||
let _ = unmount_drive(drive_letter);
|
let _ = unmount_drive(drive_letter);
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
@@ -536,6 +574,7 @@ pub async fn mount_container(
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
||||||
} else {
|
} else {
|
||||||
|
let _ = fs_clone.sync_carrier_manifest();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
print!(
|
print!(
|
||||||
@@ -588,6 +627,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 +701,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 +1022,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::*;
|
||||||
|
|||||||
+1320
-130
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);
|
||||||
|
|
||||||
|
|||||||
+663
-65
File diff suppressed because it is too large
Load Diff
+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,882 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() {
|
||||||
|
let path = temp_db_path("c02_rolling_manifest");
|
||||||
|
let carrier_size_bytes = 10 * 1024 * 1024;
|
||||||
|
let carrier_name = "test_rolling.dat";
|
||||||
|
let pass_decoy = "DecoyPassword2026!";
|
||||||
|
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||||
|
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt_0 = generate_salt();
|
||||||
|
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||||
|
let dek_0 = generate_dek();
|
||||||
|
|
||||||
|
let salt_1 = generate_salt();
|
||||||
|
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||||
|
let dek_1 = generate_dek();
|
||||||
|
|
||||||
|
let carrier_node_id = 3i64;
|
||||||
|
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
|
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&path).expect("Open database");
|
||||||
|
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 cfs = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Initial load of dual-block manifest");
|
||||||
|
|
||||||
|
// Datei anlegen -> save_manifest() -> Gen 1 -> schreibt Block 1
|
||||||
|
let file1_path = DavPath::new("/file_gen1.txt").unwrap();
|
||||||
|
let open_write = OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut file1 = cfs
|
||||||
|
.open(&file1_path, open_write)
|
||||||
|
.await
|
||||||
|
.expect("Create file1");
|
||||||
|
file1
|
||||||
|
.write_bytes(Bytes::from_static(b"generation 1 content"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file1.flush().await.unwrap();
|
||||||
|
drop(file1);
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// Verifiziere: Block 1 hat jetzt Gen 1
|
||||||
|
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Reload higher generation from block 1");
|
||||||
|
|
||||||
|
// Zweite Datei anlegen -> save_manifest() -> Gen 2 -> schreibt Block 0
|
||||||
|
let file2_path = DavPath::new("/file_gen2.txt").unwrap();
|
||||||
|
let open_write2 = OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut file2 = cfs_reloaded
|
||||||
|
.open(&file2_path, open_write2)
|
||||||
|
.await
|
||||||
|
.expect("Create file2");
|
||||||
|
file2
|
||||||
|
.write_bytes(Bytes::from_static(b"generation 2 content"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file2.flush().await.unwrap();
|
||||||
|
drop(file2);
|
||||||
|
drop(cfs_reloaded);
|
||||||
|
|
||||||
|
// 2. Simuliere Bitrot auf Block 0 (gezielt mit Rauschen überschreiben)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
let mut noise = vec![0u8; 100];
|
||||||
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||||
|
rusqlite::params![noise, carrier_node_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laden MUSS über Block 1 gelingen (Failover-Schutz)
|
||||||
|
let cfs_recovered_from_b1 = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Recovery from Block 1 must succeed despite damaged Block 0");
|
||||||
|
|
||||||
|
assert!(cfs_recovered_from_b1.metadata(&file1_path).await.is_ok());
|
||||||
|
drop(cfs_recovered_from_b1);
|
||||||
|
|
||||||
|
// 3. Simuliere Beschädigung von Block 1, während Block 0 intakt ist
|
||||||
|
let cfs_repair = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let file3_path = DavPath::new("/file_gen3.txt").unwrap();
|
||||||
|
let mut file3 = cfs_repair
|
||||||
|
.open(
|
||||||
|
&file3_path,
|
||||||
|
OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file3
|
||||||
|
.write_bytes(Bytes::from_static(b"gen 3"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file3.flush().await.unwrap();
|
||||||
|
drop(file3);
|
||||||
|
drop(cfs_repair);
|
||||||
|
|
||||||
|
// Jetzt Block 1 beschädigen
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
let mut noise = vec![0u8; 100];
|
||||||
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 1",
|
||||||
|
rusqlite::params![noise, carrier_node_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laden MUSS über Block 0 gelingen
|
||||||
|
let cfs_recovered_from_b0 = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Recovery from Block 0 must succeed despite damaged Block 1");
|
||||||
|
drop(cfs_recovered_from_b0);
|
||||||
|
|
||||||
|
// 4. Wenn BEIDE Blöcke zerstört sind: Totalverlust mit klarer Fehlermeldung
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
let mut noise = vec![0u8; 100];
|
||||||
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||||
|
rusqlite::params![noise, carrier_node_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let both_damaged = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let err_msg = match both_damaged {
|
||||||
|
Ok(_) => panic!("Both blocks damaged must return Error"),
|
||||||
|
Err(e) => e.to_string(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Kritischer Datenverlust (C-02)"),
|
||||||
|
"Error message must clearly identify C-02 failure: {}",
|
||||||
|
err_msg
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_c03_manifest_dirty_decoupling_and_unmount_flush() {
|
||||||
|
let path = temp_db_path("c03_dirty");
|
||||||
|
let carrier_size_bytes = 5 * 1024 * 1024; // 5 MB
|
||||||
|
let carrier_name = "carrier_c03.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 cfs = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("CarrierFs load");
|
||||||
|
|
||||||
|
// 1. Unmittelbar nach Mounten: nicht dirty, 0 Saves
|
||||||
|
assert_eq!(cfs.manifest_save_count(), 0);
|
||||||
|
assert!(!cfs.is_manifest_dirty());
|
||||||
|
|
||||||
|
// 2. Metadaten-Operationen: 3 Ordner anlegen
|
||||||
|
let dir1 = DavPath::new("/dir1").unwrap();
|
||||||
|
let dir2 = DavPath::new("/dir2").unwrap();
|
||||||
|
let dir3 = DavPath::new("/dir3").unwrap();
|
||||||
|
cfs.create_dir(&dir1).await.expect("create_dir dir1");
|
||||||
|
cfs.create_dir(&dir2).await.expect("create_dir dir2");
|
||||||
|
cfs.create_dir(&dir3).await.expect("create_dir dir3");
|
||||||
|
|
||||||
|
// C-03 Verifikation: Manifest ist dirty, aber save_count ist IMMER NOCH 0!
|
||||||
|
assert!(
|
||||||
|
cfs.is_manifest_dirty(),
|
||||||
|
"Manifest must be dirty after create_dir"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfs.manifest_save_count(),
|
||||||
|
0,
|
||||||
|
"No synchronous save_manifest on create_dir (C-03 decoupling)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Datei im Verzeichnis erstellen
|
||||||
|
let file_path = DavPath::new("/dir1/note.txt").unwrap();
|
||||||
|
let open_write = OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut file = cfs.open(&file_path, open_write).await.expect("open create");
|
||||||
|
assert_eq!(
|
||||||
|
cfs.manifest_save_count(),
|
||||||
|
0,
|
||||||
|
"No synchronous save_manifest on file create"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Expliziter Sync über sync_manifest()
|
||||||
|
cfs.sync_manifest().expect("sync_manifest");
|
||||||
|
assert!(
|
||||||
|
!cfs.is_manifest_dirty(),
|
||||||
|
"Manifest must be clean after sync"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfs.manifest_save_count(),
|
||||||
|
1,
|
||||||
|
"Save count incremented to 1 after sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nochmal sync: da nicht dirty, kein unnötiger Save
|
||||||
|
cfs.sync_manifest().expect("sync_manifest 2");
|
||||||
|
assert_eq!(
|
||||||
|
cfs.manifest_save_count(),
|
||||||
|
1,
|
||||||
|
"Save count remains 1 when not dirty"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. In Datei schreiben und flush() ausführen -> führt zu Manifest-Flush
|
||||||
|
file.write_bytes(Bytes::from_static(b"carrier dirty test payload"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file.flush().await.expect("file flush");
|
||||||
|
assert_eq!(
|
||||||
|
cfs.manifest_save_count(),
|
||||||
|
2,
|
||||||
|
"File flush must flush manifest"
|
||||||
|
);
|
||||||
|
assert!(!cfs.is_manifest_dirty());
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// 5. Weiteren Ordner anlegen (mark_dirty), dann cfs droppen
|
||||||
|
let dir4 = DavPath::new("/dir4").unwrap();
|
||||||
|
cfs.create_dir(&dir4).await.expect("create_dir dir4");
|
||||||
|
assert!(cfs.is_manifest_dirty());
|
||||||
|
assert_eq!(cfs.manifest_save_count(), 2);
|
||||||
|
|
||||||
|
// Drop von CarrierFs (Drop-Garantie bei Unmount)
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// 6. Neu laden und prüfen, dass dir4 persistiert wurde
|
||||||
|
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("CarrierFs reload");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cfs_reloaded
|
||||||
|
.read_dir(&dir4, ReadDirMeta::None)
|
||||||
|
.await
|
||||||
|
.is_ok(),
|
||||||
|
"Directory created before drop must exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(cfs_reloaded);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_c04_manifest_capacity_limit_and_warning() {
|
||||||
|
let path = temp_db_path("c04_capacity");
|
||||||
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
let carrier_name = "carrier_c04.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 cfs = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("CarrierFs load");
|
||||||
|
|
||||||
|
// 1. Initialer Zustand: Manifest minimal (< 5 KB, << 80%)
|
||||||
|
let (len, cap, pct) = cfs.manifest_usage();
|
||||||
|
assert_eq!(cap, sanctum::carrier::CARRIER_MANIFEST_CAPACITY);
|
||||||
|
assert!(len < 5_000);
|
||||||
|
assert!(pct < 1.0);
|
||||||
|
|
||||||
|
// 2. Fülle Manifest gezielt auf > 80% (> 838.809 Bytes)
|
||||||
|
// Erstelle Verzeichnisse mit langen Pfadnamen, bis die Größe über 840 KB liegt
|
||||||
|
for i in 0..1000 {
|
||||||
|
let dir_path = DavPath::new(&format!("/d_{:04}_{}", i, "a".repeat(1000))).unwrap();
|
||||||
|
cfs.create_dir(&dir_path).await.expect("create_dir");
|
||||||
|
let (current_len, _, _) = cfs.manifest_usage();
|
||||||
|
if current_len >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (len80, _, pct80) = cfs.manifest_usage();
|
||||||
|
assert!(
|
||||||
|
len80 >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD,
|
||||||
|
"Manifest size must exceed 80% threshold, got: {}",
|
||||||
|
len80
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pct80 >= 80.0,
|
||||||
|
"Manifest pct must be >= 80.0%, got: {}",
|
||||||
|
pct80
|
||||||
|
);
|
||||||
|
|
||||||
|
// Speichern (sync_manifest) muss mit Warnung gelingen
|
||||||
|
cfs.sync_manifest()
|
||||||
|
.expect("sync_manifest at > 80% must succeed");
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// Neu laden: muss erfolgreich laden und beim Laden die 80%-Warnung auslösen
|
||||||
|
let cfs_loaded = sanctum::carrier::CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("CarrierFs load at > 80% must succeed");
|
||||||
|
let (reloaded_len, _, reloaded_pct) = cfs_loaded.manifest_usage();
|
||||||
|
assert!(reloaded_pct >= 80.0);
|
||||||
|
assert_eq!(reloaded_len, len80);
|
||||||
|
|
||||||
|
// 3. Grenze überschreiten: weiter füllen bis > 1.048.512 Bytes
|
||||||
|
for i in 1000..1500 {
|
||||||
|
let dir_path = DavPath::new(&format!("/overflow_{:04}_{}", i, "x".repeat(1000))).unwrap();
|
||||||
|
cfs_loaded
|
||||||
|
.create_dir(&dir_path)
|
||||||
|
.await
|
||||||
|
.expect("create_dir overflow");
|
||||||
|
let (cur_len, _, _) = cfs_loaded.manifest_usage();
|
||||||
|
if cur_len > sanctum::carrier::CARRIER_MANIFEST_CAPACITY {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync_manifest() MUSS jetzt mit Fehler abbrechen
|
||||||
|
let err = cfs_loaded.sync_manifest().unwrap_err();
|
||||||
|
let err_str = err.to_string();
|
||||||
|
assert!(
|
||||||
|
err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"),
|
||||||
|
"Error message must specify carrier capacity limit: {}",
|
||||||
|
err_str
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err_str.contains("7.000 Dateien/Verzeichnisse"),
|
||||||
|
"Error message must recommend file limit: {}",
|
||||||
|
err_str
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(cfs_loaded);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|||||||
+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