Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb28c1c45 | ||
|
|
a252393f96 | ||
|
|
3693e03494 | ||
|
|
167e4b65fc | ||
|
|
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 |
+160
@@ -5,6 +5,166 @@ 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.9.1] - 2026-09-20
|
||||||
|
|
||||||
|
### Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
|
||||||
|
Dieses Release behebt die im Prüfbericht für v0.9.0 identifizierten vier Minor Issues bezüglich Metadaten-Authentifizierung beim Mounten, strikter Eingabevalidierung von Dateinamen im Carrier-Dateisystem, Dokumentation der Carrier-V1-nach-V2-Migration sowie Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung.
|
||||||
|
|
||||||
|
#### Behobene Minor Issues
|
||||||
|
- **K-01 Metadaten-MAC-Validierung beim Mount**:
|
||||||
|
- `mount_container` validiert vor dem Start des WebDAV-Dienstes die kryptografische HMAC-SHA-256 Integrität aller Metadaten für Container ab Format V3.
|
||||||
|
- Registrierung des aktiven DEK (`set_active_slot_and_dek`), sodass fortlaufende Dateioperationen den Metadaten-MAC synchron halten.
|
||||||
|
- **Lückenlose Pfadvalidierung (`validate_node_name`)**:
|
||||||
|
- Konsequente Prüfung aller Dateinamen im Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`).
|
||||||
|
- Zuverlässige Abweisung von Windows-reservierten Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`), ungültigen Zeichen (`<>:"/\|?*`), Steuerzeichen und Pfad-Traversal.
|
||||||
|
- **Sicherheitsdokumentation & Runtime-Warnung für `--legacy-names`**:
|
||||||
|
- Deutliche Warnung im CLI-Hilfetext, auf `stderr` und im Logging vor den Risiken von Swap-Attacks bei deaktivierter AAD-Bindung.
|
||||||
|
- Aufnahme einer Risikobewertung in `README.md` und `THREAT_MODEL.md` (Abschnitt 7).
|
||||||
|
- **Migration-Leitfaden (V1 → V2)**:
|
||||||
|
- Schritt-für-Schritt-Anleitung in `README.md` (Abschnitt 10) zur transparenten Aktualisierung bestehender V1-Container auf das Paged-Manifest-Format V2.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-09-20
|
||||||
|
|
||||||
|
### Paged Manifest für den Hidden Vault (Aufhebung der Kapazitätsgrenze)
|
||||||
|
Dieses Release führt das **Carrier-Format V2** für den Hidden Vault ein. Es hebt die bisherige Kapazitätsgrenze von ca. 7.000 Dateien vollständig auf, führt eine skalierbare Paged-Manifest-Architektur ein und optimiert Performanz sowie Resilienz signifikant.
|
||||||
|
|
||||||
|
#### Formaterweiterung Carrier V2 (D-01 bis D-05)
|
||||||
|
- **Carrier-Format V2 mit Paged Manifest**:
|
||||||
|
- Trennung von Superblock und Inode-Tabellen: Block 0 und Block 1 enthalten nur noch den leichtgewichtigen `CarrierSuperblock` (unter Erhalt der C-02 Dual-Block Redundanz).
|
||||||
|
- Inodes werden in separaten Inode-Pages (`CarrierInodePage`, Magic `SANCTPAG`, ~1 MB Payload je Block, ca. 4.500–7.000 Inodes pro Seite) aus dem normalen Blockpool verwaltet.
|
||||||
|
- Dynamische Skalierung: Die Inode-Kapazität skaliert linear mit den verfügbaren Trägerblöcken bis zu 2^32 Blöcken.
|
||||||
|
- **D-01: Paged Loading & Fail-Soft Resilienz**:
|
||||||
|
- Erkennt automatisch V1- vs. V2-Superblöcke auf Block 0 und 1.
|
||||||
|
- Resiliente Fehlerisolation: Beschädigte oder unlesbare Inode-Seiten führen nicht zum Mount-Abbruch. Fehlerhafte Seiten werden geloggt (`error!`) und übersprungen, während alle intakten Inodes gemountet werden.
|
||||||
|
- Neuer Zähler `corrupted_pages` warnt den Benutzer beim Mounten über partielle Datenbeschädigungen.
|
||||||
|
- **D-02: Sekundärindex für Kindknoten (`children_index`)**:
|
||||||
|
- In-Memory Index `children_index: HashMap<i64, Vec<i64>>` ordnet Elternknoten-IDs ihren Kindern zu.
|
||||||
|
- Beschleunigt Pfadauflösung (`resolve_path`) und Verzeichnislisting (`read_dir`) von O(N) Tabellenscans auf O(Geschwister).
|
||||||
|
- Konsistente Nachführung bei `open`, `create_dir`, `remove_file`, `remove_dir`, `rename` und `copy`.
|
||||||
|
- **D-03: Transparente V1 → V2 Migration**:
|
||||||
|
- V1-Container werden vollkommen transparent eingelesen.
|
||||||
|
- Beim ersten Speichervorgang (`save_manifest()`) wird geprüft, ob genügend freie Blöcke für die Seitenaufteilung vorhanden sind.
|
||||||
|
- Reicht der Platz nicht aus, bricht der Vorgang sauber ab, ohne den Container zu beschädigen. Reicht der Platz, wird der Container nahtlos auf Format V2 migriert.
|
||||||
|
- Eine ausführliche Schritt-für-Schritt-Anleitung zur Migration findet sich in der [README.md](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
|
||||||
|
- **D-04 & D-05: Deterministische Seitenaufteilung & Blockfreigabe**:
|
||||||
|
- Deterministische Sortierung der Inodes nach ID garantiert reproduzierbare Seitenbelegungen.
|
||||||
|
- Dynamische Blockfreigabe: Verringert sich die Inode-Anzahl (z. B. durch Löschungen), werden überzählige Seitenblöcke mittels kryptografischem Zufallsrauschen geschreddert (`shred_carrier_block`) und an den Freispeicher zurückgegeben.
|
||||||
|
- Dynamische Block-Warnung: Die statische 80%-Manifestgrenze aus C-04 wird durch eine Warnung bei absolutem Träger-Blockmangel (< 20 Blöcke oder < 5% Restkapazität) ersetzt.
|
||||||
|
|
||||||
|
## [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.9.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.9.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -37,6 +37,8 @@ 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ät & Skalierung (Format V2)**: Dank Paged-Manifest-Architektur dynamisch skalierbar (~4.500–7.000 Dateien je 1-MB-Manifestseite, keine starre 7.000er-Grenze mehr). Sanctum warnt beim Mounten automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
|
||||||
|
* **Migration von V1-Containern**: Bestehende V1-Hidden-Vaults werden transparent unterstützt und können auf Format V2 migriert werden. Siehe [Schritt-für-Schritt-Anleitung in der README](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
|
||||||
* **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 +75,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,8 +37,11 @@ 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).
|
||||||
- **Dateinamen-Verschlüsselung**:
|
*Carrier-Format V2 mit Paged Manifest*: Das steganografische Dateisystem des Hidden Vaults nutzt eine skalierbare Paged-Manifest-Architektur. Blöcke 0 und 1 speichern den redundanten Superblock (C-02), während Inodes über dedizierte Inode-Pages (~1 MB Nutzdaten je Seite, ca. 4.500–7.000 Inodes pro Seite) dynamisch aus dem Blockpool verwaltet werden. Die Kapazität ist nicht mehr auf 7.000 Dateien limitiert, sondern skaliert dynamisch mit den verfügbaren Trägerblöcken. Robuste Fail-Soft-Resilienz (D-01) isoliert Seitenbeschädigungen, ein In-Memory Sekundärindex (D-02) beschleunigt Pfadoperationen auf O(Geschwister), und Sanctum warnt beim Einbinden automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
|
||||||
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-Verschlüsselung & Schutz vor Swap-Attacks**:
|
||||||
|
Dateinamen werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert, wobei die übergeordnete Verzeichnis-ID (`parent_id`) als Additional Authenticated Data (AAD) eingebunden ist. Dies verhindert, dass Angreifer verschlüsselte Dateinamen zwischen Verzeichnissen vertauschen können.
|
||||||
|
> [!WARNING]
|
||||||
|
> **Sicherheitshinweis zu `--legacy-names`**: Das globale Flag `--legacy-names` deaktiviert die Prüfung der AAD-Bindung bei der Entschlüsselung von Dateinamen. Verwenden Sie dieses Flag **ausschließlich zur einmaligen Datenrettung oder Migration** älterer Container (vor Version 0.8.0), da es das Schutzniveau gegen Manipulationen reduziert.
|
||||||
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
||||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene.
|
Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene.
|
||||||
*Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen.
|
*Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen.
|
||||||
@@ -58,6 +61,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 +113,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 +130,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 +201,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
|
||||||
|
|
||||||
@@ -225,6 +246,46 @@ sanctum.exe upgrade --force
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 10. Migration von Carrier-Format V1 auf V2 (Hidden Vault)
|
||||||
|
|
||||||
|
Mit Sanctum v0.9.0 wurde das **Carrier-Format V2** eingeführt, das die vorherige Begrenzung von ca. 7.000 Dateien im Hidden Vault aufhebt. Bestehende V1-Container werden transparent unterstützt und können wie folgt auf V2 aktualisiert werden:
|
||||||
|
|
||||||
|
1. **Sicherheits-Backup erstellen**:
|
||||||
|
Erstellen Sie vor der Migration eine Sicherungskopie Ihres Containers:
|
||||||
|
```powershell
|
||||||
|
sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "C:\Pfad\tresor_backup.sanctum"
|
||||||
|
# Oder manuell:
|
||||||
|
Copy-Item "C:\Pfad\tresor.sanctum" "C:\Pfad\tresor_backup.sanctum"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Freispeicher prüfen**:
|
||||||
|
Für die Migration wird mindestens ein freier Trägerblock (~1 MB) für die erste Inode-Page benötigt. Bei extrem vollen Trägerdateien (0 freie Blöcke) bricht Sanctum die Migration sicher ab (`Out of space`), ohne den V1-Container zu verändern oder zu beschädigen.
|
||||||
|
|
||||||
|
3. **Hidden Vault einbinden (`mount`)**:
|
||||||
|
Öffnen Sie den Hidden Vault mit Ihrem Hidden-Passwort:
|
||||||
|
```powershell
|
||||||
|
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
|
||||||
|
```
|
||||||
|
Sanctum erkennt automatisch das V1-Format, liest das bisherige Manifest ein und bereitet die V2-Struktur im Speicher vor.
|
||||||
|
|
||||||
|
4. **Migration durch Schreiboperation auslösen**:
|
||||||
|
Die Migration auf die V2-On-Disk-Struktur wird beim nächsten Speichervorgang des Manifests automatisch vollzogen. Sie können dies durch Anlegen und sofortiges Löschen einer temporären Datei anstoßen:
|
||||||
|
```powershell
|
||||||
|
New-Item "S:\.migrate" -ItemType File
|
||||||
|
Remove-Item "S:\.migrate"
|
||||||
|
sanctum.exe unmount --drive S
|
||||||
|
```
|
||||||
|
Beim Aushängen (`unmount`) wird das neue Paged-Manifest (V2-Superblock + Inode-Pages) atomar auf die Trägerdatei geschrieben.
|
||||||
|
|
||||||
|
5. **Integrität verifizieren**:
|
||||||
|
Überprüfen Sie den migrierten Container:
|
||||||
|
```powershell
|
||||||
|
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
|
||||||
|
```
|
||||||
|
Beim nächsten Mounten des Hidden Vaults zeigt Sanctum die dynamische Block- und Seitenstatistik des V2-Formats an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛡️ OpSec & Explorer Anti-Leak Shield
|
## 🛡️ OpSec & Explorer Anti-Leak Shield
|
||||||
|
|
||||||
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
||||||
@@ -291,6 +352,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,143 @@
|
|||||||
|
# Sanctum Security Audit & Remediation Log (v0.9.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. Abschnitt 5 dokumentiert die Formaterweiterung Carrier V2 (Paged Manifest) aus v0.9.0, und Abschnitt 6 die Nachbesserungen der Minor Issues aus v0.9.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit-Zusammenfassung
|
||||||
|
|
||||||
|
- **Zielversion:** Sanctum v0.9.1 (Basis v0.9.0)
|
||||||
|
- **Behobene Findings:** 39 / 39 (100%)
|
||||||
|
- **Test-Ergebnis:** 128 / 128 Tests erfolgreich (100% Pass Rate)
|
||||||
|
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
|
||||||
|
- **Container-Format:** Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) beim Mount und Chunk-Replay-Schutz (Generation-gebundenes AAD).
|
||||||
|
- **Hidden Vault & Storage-Resilienz:** Carrier-Format V2 (Paged Manifest), Dual-Block rollierender Superblock (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Fail-Soft Inode-Isolation (D-01), Sekundärindex (D-02), lückenlose Pfadvalidierung (`validate_node_name`), dynamische Kapazitätsskalierung und Blockwarnung, 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.
|
||||||
|
### 5. Sanctum v0.9.0 — Formaterweiterung Carrier V2 (Paged Manifest)
|
||||||
|
- **Aufhebung der C-04 Manifest-Kapazitätsgrenze:**
|
||||||
|
- In v0.8.1 war das Manifest ein einzelnes JSON-Objekt auf Block 0 und Block 1 (limitiert auf ca. 7.000 Inodes).
|
||||||
|
- Mit Format V2 wird der `CarrierSuperblock` (Block 0 & 1, dual-block rollierend C-02) von den eigentlichen Inode-Daten entkoppelt.
|
||||||
|
- Inodes werden in separaten `CarrierInodePage`-Blöcken (~1 MB Payload je Seite, ca. 4.500–7.000 Inodes pro Seite) verwaltet, dynamisch allokiert aus dem Blockpool.
|
||||||
|
- **Paged Loading & Fail-Soft Resilienz (D-01):**
|
||||||
|
- Isolierte Fehlerbehandlung: Beschädigte oder unlesbare Inode-Seiten führen nicht mehr zum Gesamtabbruch des Mounts.
|
||||||
|
- Das System lädt alle lesbaren Seiten, loggt fehlerhafte Seiten als `error!` und führt einen Zähler `corrupted_pages`, der beim Einbinden gut sichtbar gemeldet wird.
|
||||||
|
- **Sekundärindex für Kindknoten (`children_index`, D-02):**
|
||||||
|
- `CarrierFsInner` pflegt einen `HashMap<i64, Vec<i64>>` In-Memory Index für Vater-Kind-Beziehungen.
|
||||||
|
- Reduziert Pfadauflösung (`resolve_path`) und Verzeichnis-Listings (`read_dir`) von $O(N)$ Tabellenscans auf $O(\text{Geschwister})$.
|
||||||
|
- **Transparente Migration & Allokations-Sicherheit (D-03, D-04, D-05):**
|
||||||
|
- V1-Container werden transparent geladen und beim ersten Schreibvorgang nach V2 migriert — inklusive Vorabprüfung, ob genügend freie Blöcke vorhanden sind.
|
||||||
|
- Freigabe überzähliger Seitenblöcke (`shred_carrier_block`) bei Inode-Löschungen.
|
||||||
|
- Ersetzung der statischen 80%-Füllstandswarnung durch eine Block-Verfügbarkeitswarnung bei `< 20` freien Blöcken oder `< 5%` Restkapazität.
|
||||||
|
- **Automatisierte Testabdeckung:** 7 neue Regressionstests in `tests/carrier_v2_paged_manifest_test.rs` decken Kapazität (>10.000 Inodes), Migration, Fail-Soft-Isolation, Sekundärindex-Konsistenz, Pfadauflösungs-Performance, Out-of-Space-Abbruch und Blockfreigabe ab (Gesamttests: 130/130 erfolgreich).
|
||||||
|
|
||||||
|
### 6. Sanctum v0.9.1 — Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
|
||||||
|
- **Metadaten-MAC-Validierung beim Mount (K-01):**
|
||||||
|
- `mount_container` validiert vor dem Bereitstellen des Dateisystems die Integrität der SQLite-Metadaten via `db.verify_metadata_mac_for_slot`.
|
||||||
|
- Bei Manipulation bricht der Vorgang fail-closed ab; nachfolgende VFS-Operationen führen den MAC über den aktiven DEK fort.
|
||||||
|
- **Lückenlose Pfadvalidierung (`validate_node_name`):**
|
||||||
|
- Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`) validieren Knotennamen lückenlos gegen Windows-Reservierungen (`CON`, `PRN`, `AUX`, `NUL`, etc.) und verbotene Steuerzeichen.
|
||||||
|
- **Dokumentation & Runtime-Warnung zu `--legacy-names`:**
|
||||||
|
- Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung; explizite Warnung im CLI-Hilfetext, auf stderr und im Logging bei Aktivierung.
|
||||||
|
- **Migration-Dokumentation (V1 → V2):**
|
||||||
|
- Detaillierte 5-stufige Dokumentation für Anwender zur Konvertierung auf das Paged-Manifest-Format V2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikationsnachweis
|
||||||
|
|
||||||
|
Alle Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt (130 / 130 Tests). Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Threat Model & Sicherheitsarchitektur von Sanctum
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.1** 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. |
|
||||||
|
| **Dateinamen-Swap-Angriffe** | Angreifer vertauscht verschlüsselte Dateinamen zwischen Verzeichnissen. | AAD-Bindung an `parent_id` bei AES-256-GCM Verschlüsselung; Warnung vor `--legacy-names` (deaktiviert diesen Schutz). |
|
||||||
|
| **Path-Traversal & Gerätenamen** | Angreifer schleust Pfad-Traversal (`..`), reservierte Namen (`CON`, `PRN`) oder Null-Bytes ein. | Strikte Validierung (`validate_node_name`) in allen Schichten (`storage.rs`, `vfs.rs`, `carrier.rs`, `sync.rs`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hidden Vault Carrier-Dateisystem & Redundanzmodell (Format V2)
|
||||||
|
|
||||||
|
### 6.1 Redundanzhierarchie: Superblock vs. Inode-Pages
|
||||||
|
Mit Sanctum v0.9.0 führt der Hidden Vault das Carrier-Format V2 (Paged Manifest) ein. Das Redundanzmodell unterscheidet bewusst zwischen Metadaten-Knotenpunkten und Inode-Seiten:
|
||||||
|
|
||||||
|
1. **Carrier-Superblock (Vollständige Redundanz auf Block 0 & Block 1)**:
|
||||||
|
- Der Superblock enthält die essenziellen Dateisystem-Parameter (`root_id`, `next_inode_id`, `free_blocks`, `page_block_indices`) sowie einen monotonen Generationszähler (`manifest_generation`) und CRC32-Prüfsummen.
|
||||||
|
- Er wird abwechselnd auf Block 0 und Block 1 geschrieben (rollierendes C-02-Schema). Ist einer der beiden Blöcke beschädigt oder unvollständig geschrieben, stellt Sanctum den Superblock transparent aus dem intakten Block wieder her.
|
||||||
|
2. **Inode-Pages (Dynamisch allokiert, nicht redundant dupliziert)**:
|
||||||
|
- Inodes werden in 1-MB-Seiten (`CarrierInodePage`, Magic `SANCTPAG`) im normalen Träger-Blockpool gespeichert.
|
||||||
|
- Zur Maximierung der nutzbaren Speicherkapazität werden Inode-Pages **nicht** blockweise gespiegelt. Stattdessen schützt Sanctum die Konsistenz über Fail-Soft-Isolation (D-01): Fällt eine einzelne Seite durch Bitrot oder Entschlüsselungsfehler aus, wird ausschließlich diese Seite übersprungen. Das restliche Dateisystem bleibt vollständig mountbar und lesbar.
|
||||||
|
- Erkannte Seitenbeschädigungen werden beim Mounten und in den Systemlogs (`corrupted_pages`) unübersehbar gemeldet.
|
||||||
|
|
||||||
|
### 6.2 Pfadauflösungs- und DoS-Schutz (`children_index`, D-02)
|
||||||
|
- Bei sehr großen Dateisystemen (>10.000 Inodes) führt eine lineare Suche über alle Inodes bei jedem Pfadsegment zu quadratischer Laufzeitkomplexität ($O(N)$ pro Segment).
|
||||||
|
- Sanctum v0.9.0 hält einen In-Memory-Sekundärindex (`children_index: HashMap<i64, Vec<i64>>`), der Pfadauflösungen auf $O(\text{Geschwister})$ reduziert. Dies verhindert CPU-Erschöpfungs-Angriffe (ReDoS/Algorithmic Complexity Attacks) beim Traversieren tiefer Verzeichnisstrukturen.
|
||||||
|
|
||||||
|
### 6.3 Speicherallokationsgrenzen & Transaktionssicherheit (D-03, D-05)
|
||||||
|
- **Vorabprüfung bei Migration (D-03)**: Bei der transparenten Konvertierung alter V1-Manifeste nach V2 prüft Sanctum vorab, ob ausreichend freie Trägerblöcke vorhanden sind. Reicht der Speicherplatz nicht aus, wird der Speichervorgang ohne Beschädigung des V1-Containers abgebrochen.
|
||||||
|
- **Kryptografische Blockfreigabe (D-05)**: Werden Inodes gelöscht und Inode-Pages überflüssig, werden die freigegebenen Trägerblöcke sofort mit CSPRNG-Rauschen überschrieben (`shred_carrier_block`), bevor sie an den Freispeicher-Pool zurückgegeben werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dateinamen-Sicherheit und das `--legacy-names`-Flag
|
||||||
|
|
||||||
|
### 7.1 AAD-Bindung von Dateinamen
|
||||||
|
In Sanctum werden Dateinamen mit AES-256-GCM verschlüsselt. Um Swap-Angriffe zu verhindern (bei denen ein Angreifer mit Dateisystem-Zugriff verschlüsselte Dateinamen zwischen Verzeichnissen austauscht), wird die `parent_id` als zusätzliche authentifizierte Daten (AAD) an die Verschlüsselung gebunden.
|
||||||
|
|
||||||
|
### 7.2 Sicherheitsrisiko des `--legacy-names`-Flags
|
||||||
|
Frühere Versionen von Sanctum (vor v0.8.0) verwendeten leere AAD für Dateinamen. Das Flag `--legacy-names` erlaubt die Entschlüsselung von Dateinamen ohne AAD-Bindung als Fallback.
|
||||||
|
- **Risiko**: Bei aktiviertem `--legacy-names` ist der Schutz vor Vertauschen von Dateinamen aufgehoben. Ein Angreifer könnte Dateien manipulieren, indem er verschlüsselte Namen zwischen verschiedenen Verzeichnissen vertauscht.
|
||||||
|
- **Empfehlung**: Dieses Flag darf **ausschließlich** zur einmaligen Migration von Altdaten verwendet werden. Im regulären Betrieb muss es deaktiviert bleiben.
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": "0.6.0",
|
"version": "0.9.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.9.1/sanctum-v0.9.1-windows-x86_64.zip",
|
||||||
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
|
"hash": "0409d5e67c173983693456620b217e9498dcdc1ae54a6bc67aa8e368ee3d0883",
|
||||||
"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.9.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.9.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.9.1/sanctum-v0.9.1-windows-x86_64.zip
|
||||||
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
|
InstallerSha256: 0409d5e67c173983693456620b217e9498dcdc1ae54a6bc67aa8e368ee3d0883
|
||||||
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 {
|
||||||
|
|||||||
+805
-60
File diff suppressed because it is too large
Load Diff
+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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-2
@@ -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::{
|
||||||
@@ -25,7 +25,7 @@ use sanctum::verify::verify_container;
|
|||||||
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Erlaubt Legacy-Namensentschlüsselung ohne deterministisches AAD (S-07)
|
/// SICHERHEITSHINWEIS: Unsicher! Deaktiviert die kryptografische Bindung (AAD) von Dateinamen (Gefahr von Swap-Attacks/Manipulation). Nur zur temporären Migration alter Container verwenden!
|
||||||
#[arg(long, global = true, default_value_t = false)]
|
#[arg(long, global = true, default_value_t = false)]
|
||||||
legacy_names: bool,
|
legacy_names: bool,
|
||||||
|
|
||||||
@@ -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",
|
||||||
@@ -1390,6 +1591,8 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
if cli.legacy_names {
|
if cli.legacy_names {
|
||||||
set_allow_legacy_names(true);
|
set_allow_legacy_names(true);
|
||||||
|
eprintln!("WARNUNG: Das Flag '--legacy-names' ist aktiv. Die kryptografische Authentifizierung von Dateinamen ist geschwächt (Gefahr von Swap-Attacks). Verwenden Sie dieses Flag nur zur Datenmigration!");
|
||||||
|
tracing::warn!("Flag --legacy-names ist aktiv. Authenticated Associated Data (AAD) fuer Dateinamen ist abgeschwaecht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -1421,6 +1624,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 +1677,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 +1719,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 +1747,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(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-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,70 @@ 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)?;
|
||||||
|
|
||||||
|
// K-01: Metadaten-Integrität via HMAC-SHA256 validieren (Format V3)
|
||||||
|
if version >= crate::crypto::FORMAT_VERSION_V3 {
|
||||||
|
match db.verify_metadata_mac_for_slot(vault_id, &dek) {
|
||||||
|
Ok(true) => {
|
||||||
|
if !stealth {
|
||||||
|
println!(
|
||||||
|
" • Metadaten-MAC: ✔ Integrität erfolgreich verifiziert (HMAC-SHA256)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
bail!(
|
||||||
|
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
bail!("Fehler bei der Metadaten-MAC-Verifikation: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.set_active_slot_and_dek(vault_id, dek.clone());
|
||||||
|
|
||||||
// 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 +305,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 +423,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 +444,29 @@ 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(corrupted) = fs_clone.carrier_corrupted_pages() {
|
||||||
|
if corrupted > 0 {
|
||||||
|
println!(
|
||||||
|
" {} Warnung (D-01): {} beschädigte Inode-Tabellenseite(n) im Hidden Vault übersprungen!",
|
||||||
|
ui::red("[!]"),
|
||||||
|
corrupted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((free_blocks, total_blocks)) = fs_clone.carrier_block_stats() {
|
||||||
|
if total_blocks > 0
|
||||||
|
&& (free_blocks < 20 || (free_blocks as f64 / total_blocks as f64) < 0.05)
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
" {} Warnung: Hidden Vault hat nur noch {} freie Blöcke (von {}).",
|
||||||
|
ui::yellow("[!]"),
|
||||||
|
free_blocks,
|
||||||
|
total_blocks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
println!(
|
println!(
|
||||||
@@ -522,6 +588,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 +603,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 +656,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 +730,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 +1051,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::*;
|
||||||
|
|||||||
+1378
-134
File diff suppressed because it is too large
Load Diff
+516
-82
@@ -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
|
||||||
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
|
if bytes_written == 0 {
|
||||||
db.update_node_size_and_time(node_id, local_size, local_mtime)?;
|
// 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))?;
|
||||||
|
}
|
||||||
|
// 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,46 +549,123 @@ 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
|
||||||
format!(
|
// Verhindert inkonsistente / beschädigte Zieldateien bei Abbruch oder I/O-Fehlern
|
||||||
"Konnte Zieldatei '{}' nicht erstellen",
|
let parent_dir = local_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
local_path.display()
|
let temp_path = parent_dir.join(format!(
|
||||||
)
|
".sanctum_pull_{}_{}.tmp",
|
||||||
})?;
|
std::process::id(),
|
||||||
|
rand::random::<u64>()
|
||||||
|
));
|
||||||
|
|
||||||
let total_chunks = if node.size == 0 {
|
let write_res = (|| -> Result<()> {
|
||||||
0
|
let mut out_file = File::create(&temp_path).with_context(|| {
|
||||||
} else {
|
format!(
|
||||||
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
"Konnte temporäre Zieldatei '{}' nicht erstellen",
|
||||||
};
|
temp_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
for idx in 0..total_chunks {
|
let total_chunks = if node.size == 0 {
|
||||||
if let Some(record) = db.read_chunk(node.id, idx)? {
|
0
|
||||||
let plaintext = decrypt_chunk(
|
|
||||||
dek,
|
|
||||||
node.id,
|
|
||||||
idx,
|
|
||||||
&record.ciphertext,
|
|
||||||
&record.nonce,
|
|
||||||
&record.tag,
|
|
||||||
version,
|
|
||||||
)?;
|
|
||||||
out_file.write_all(&plaintext)?;
|
|
||||||
} else {
|
} else {
|
||||||
bail!(
|
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
||||||
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
|
};
|
||||||
idx,
|
|
||||||
node.name
|
for idx in 0..total_chunks {
|
||||||
|
if let Some(record) = db.read_chunk(node.id, idx)? {
|
||||||
|
let plaintext = decrypt_chunk(
|
||||||
|
dek,
|
||||||
|
node.id,
|
||||||
|
idx,
|
||||||
|
&record.ciphertext,
|
||||||
|
&record.nonce,
|
||||||
|
&record.tag,
|
||||||
|
version,
|
||||||
|
record.generation,
|
||||||
|
)?;
|
||||||
|
out_file.write_all(&plaintext)?;
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
|
||||||
|
idx,
|
||||||
|
node.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out_file.flush()?;
|
||||||
|
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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out_file.flush()?;
|
// Atomares Ersetzen der Zieldatei
|
||||||
set_local_file_mtime(&out_file, node.modified_at);
|
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);
|
||||||
|
|
||||||
|
|||||||
+678
-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,889 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Format V2 (v0.9.0): Die 1-MB-Grenze bricht NICHT mehr ab, sondern allokiert eine zusätzliche Seite
|
||||||
|
cfs_loaded
|
||||||
|
.sync_manifest()
|
||||||
|
.expect("sync_manifest with > 1 MB must succeed in V2 via paged manifest");
|
||||||
|
assert!(
|
||||||
|
cfs_loaded.page_count() >= 2,
|
||||||
|
"Page count must expand to at least 2 pages"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reloaden und prüfen, ob beide Seiten geladen werden
|
||||||
|
drop(cfs_loaded);
|
||||||
|
let cfs_paged = 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 paged manifest");
|
||||||
|
assert!(cfs_paged.page_count() >= 2);
|
||||||
|
drop(cfs_paged);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use dav_server::davpath::DavPath;
|
||||||
|
use dav_server::fs::{DavFileSystem, OpenOptions};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
use sanctum::carrier::{
|
||||||
|
write_carrier_block, CarrierFs, CarrierFsInner, CarrierInode, CarrierManifest, CARRIER_MAGIC,
|
||||||
|
CARRIER_VERSION, CARRIER_VERSION_V2,
|
||||||
|
};
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
||||||
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::{Database, UnlockedKeys};
|
||||||
|
|
||||||
|
fn temp_db_path(prefix: &str) -> PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let id: u64 = OsRng.next_u64();
|
||||||
|
path.push(format!("sanctum_test_v2_{}_{}.sanctum", prefix, id));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestEnv {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub db: Database,
|
||||||
|
pub carrier_node_id: i64,
|
||||||
|
pub auth_hidden: UnlockedKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestEnv {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_carrier_env(prefix: &str, size_mb: usize) -> TestEnv {
|
||||||
|
let path = temp_db_path(prefix);
|
||||||
|
let carrier_size_bytes = (size_mb * 1024 * 1024) as u64;
|
||||||
|
let carrier_name = "carrier_v2.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");
|
||||||
|
|
||||||
|
TestEnv {
|
||||||
|
path,
|
||||||
|
db,
|
||||||
|
carrier_node_id,
|
||||||
|
auth_hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_carrier_fs(env: &TestEnv) -> CarrierFs {
|
||||||
|
CarrierFs::load(
|
||||||
|
env.db.clone(),
|
||||||
|
env.carrier_node_id,
|
||||||
|
std::sync::Arc::new(env.auth_hidden.carrier_dek().unwrap().clone()),
|
||||||
|
std::sync::Arc::new(env.auth_hidden.dek().clone()),
|
||||||
|
env.auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Load CarrierFs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1. Kapazitätstest: >10.000 Inodes künstlich erzeugen, über mehrere Seiten speichern und neu laden
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_capacity_large_inode_table() {
|
||||||
|
let env = setup_carrier_env("capacity_large", 25);
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
// Initial hat ein frischer Container 1 Seite (Block 2)
|
||||||
|
assert_eq!(cfs.page_count(), 1);
|
||||||
|
|
||||||
|
// 10.000 Inodes direkt in den Speicher einfügen
|
||||||
|
{
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
for i in 0..10_000 {
|
||||||
|
let id = inner.manifest.next_inode_id;
|
||||||
|
inner.manifest.next_inode_id += 1;
|
||||||
|
let inode = CarrierInode {
|
||||||
|
id,
|
||||||
|
parent_id: Some(1),
|
||||||
|
name: format!("file_{:05}.txt", i),
|
||||||
|
is_dir: false,
|
||||||
|
size: i % 500,
|
||||||
|
created_at: 1_700_000_000 + i,
|
||||||
|
modified_at: 1_700_000_000 + i,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
};
|
||||||
|
inner.manifest.inodes.insert(id, inode);
|
||||||
|
inner.index_add_child(1, id);
|
||||||
|
}
|
||||||
|
inner.mark_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichern erzwingen
|
||||||
|
cfs.sync_manifest().expect("Sync 10.000 inodes");
|
||||||
|
|
||||||
|
// In V2 müssen die 10.000 Inodes auf mindestens 2 Seiten aufgeteilt worden sein
|
||||||
|
let pages_after_save = cfs.page_count();
|
||||||
|
assert!(
|
||||||
|
pages_after_save >= 2,
|
||||||
|
"10.000 Inodes müssen auf mindestens 2 Seiten aufgeteilt werden, tatsächlich: {}",
|
||||||
|
pages_after_save
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// Neu aus Trägerdatei laden
|
||||||
|
let cfs_reloaded = load_carrier_fs(&env);
|
||||||
|
assert_eq!(cfs_reloaded.page_count(), pages_after_save);
|
||||||
|
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
|
||||||
|
|
||||||
|
// Prüfe, dass beliebige Dateien korrekt existieren und Pfade auflösen
|
||||||
|
let test_file_1 = cfs_reloaded.resolve_path("/file_00000.txt");
|
||||||
|
assert!(test_file_1.is_some());
|
||||||
|
assert_eq!(test_file_1.unwrap().name, "file_00000.txt");
|
||||||
|
|
||||||
|
let test_file_mid = cfs_reloaded.resolve_path("/file_04999.txt");
|
||||||
|
assert!(test_file_mid.is_some());
|
||||||
|
assert_eq!(test_file_mid.unwrap().name, "file_04999.txt");
|
||||||
|
|
||||||
|
let test_file_end = cfs_reloaded.resolve_path("/file_09999.txt");
|
||||||
|
assert!(test_file_end.is_some());
|
||||||
|
assert_eq!(test_file_end.unwrap().name, "file_09999.txt");
|
||||||
|
|
||||||
|
let nonexistent = cfs_reloaded.resolve_path("/file_10000.txt");
|
||||||
|
assert!(nonexistent.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2. Migrationstest: Einen V1-Container laden, modifizieren, als V2 speichern und verifizieren
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_v1_to_v2_migration() {
|
||||||
|
let env = setup_carrier_env("migration_v1_v2", 10);
|
||||||
|
|
||||||
|
// Einen V1-Container konstruieren: Block 0 und Block 1 halten CarrierManifest mit version = 1
|
||||||
|
let mut v1_manifest = CarrierManifest {
|
||||||
|
magic: *CARRIER_MAGIC,
|
||||||
|
version: CARRIER_VERSION, // 1
|
||||||
|
manifest_generation: 1,
|
||||||
|
total_blocks: 10,
|
||||||
|
free_blocks: (2..10).collect(),
|
||||||
|
next_inode_id: 3,
|
||||||
|
page_block_indices: Vec::new(),
|
||||||
|
inodes: HashMap::new(),
|
||||||
|
};
|
||||||
|
v1_manifest.inodes.insert(
|
||||||
|
1,
|
||||||
|
CarrierInode {
|
||||||
|
id: 1,
|
||||||
|
parent_id: None,
|
||||||
|
name: String::new(),
|
||||||
|
is_dir: true,
|
||||||
|
size: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
modified_at: 1000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
v1_manifest.inodes.insert(
|
||||||
|
2,
|
||||||
|
CarrierInode {
|
||||||
|
id: 2,
|
||||||
|
parent_id: Some(1),
|
||||||
|
name: "original_v1.txt".to_string(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 128,
|
||||||
|
created_at: 1000,
|
||||||
|
modified_at: 1000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
|
||||||
|
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
|
||||||
|
let dek_inner = env.auth_hidden.dek();
|
||||||
|
write_carrier_block(
|
||||||
|
&env.db,
|
||||||
|
env.carrier_node_id,
|
||||||
|
0,
|
||||||
|
&dek_outer,
|
||||||
|
&dek_inner,
|
||||||
|
&v1_bytes,
|
||||||
|
env.auth_hidden.version(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_carrier_block(
|
||||||
|
&env.db,
|
||||||
|
env.carrier_node_id,
|
||||||
|
1,
|
||||||
|
&dek_outer,
|
||||||
|
&dek_inner,
|
||||||
|
&v1_bytes,
|
||||||
|
env.auth_hidden.version(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Unter v0.9.0 laden: V1-Format wird transparent erkannt
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
{
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
assert_eq!(inner.manifest.version, CARRIER_VERSION);
|
||||||
|
}
|
||||||
|
let orig = cfs.resolve_path("/original_v1.txt");
|
||||||
|
assert!(orig.is_some());
|
||||||
|
assert_eq!(orig.unwrap().id, 2);
|
||||||
|
|
||||||
|
// 2. Änderung vornehmen: Datei hinzufügen (löst Dirty-Tracking aus)
|
||||||
|
let new_file_path = DavPath::new("/new_v2.txt").unwrap();
|
||||||
|
let mut file = cfs
|
||||||
|
.open(
|
||||||
|
&new_file_path,
|
||||||
|
OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Create new file in migrated container");
|
||||||
|
file.write_bytes(Bytes::from_static(b"migrated to v2"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file.flush().await.unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// Explizit synchronisieren
|
||||||
|
cfs.sync_manifest().expect("Sync manifest during migration");
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// 3. Neu laden: Jetzt muss das Dateisystem im Format V2 vorliegen
|
||||||
|
let cfs_reloaded = load_carrier_fs(&env);
|
||||||
|
{
|
||||||
|
let inner = cfs_reloaded.inner.lock().unwrap();
|
||||||
|
assert_eq!(inner.manifest.version, CARRIER_VERSION_V2);
|
||||||
|
assert!(!inner.manifest.page_block_indices.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beide Dateien müssen vorhanden sein
|
||||||
|
assert!(cfs_reloaded.resolve_path("/original_v1.txt").is_some());
|
||||||
|
assert!(cfs_reloaded.resolve_path("/new_v2.txt").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3. Teilausfalltest (D-01 Fail-Soft): Beschädigung einer einzelnen Inode-Seite führt nicht zum Totalverlust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_partial_page_corruption_resilience() {
|
||||||
|
let env = setup_carrier_env("partial_corruption", 20);
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
// Genügend Inodes erzeugen, um 2 Seiten zu belegen
|
||||||
|
{
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
for i in 0..1500 {
|
||||||
|
let id = inner.manifest.next_inode_id;
|
||||||
|
inner.manifest.next_inode_id += 1;
|
||||||
|
let name = if i < 750 {
|
||||||
|
format!("alpha_{:04}_{}", i, "a".repeat(800))
|
||||||
|
} else {
|
||||||
|
format!("omega_{:04}_{}", i, "z".repeat(800))
|
||||||
|
};
|
||||||
|
let inode = CarrierInode {
|
||||||
|
id,
|
||||||
|
parent_id: Some(1),
|
||||||
|
name,
|
||||||
|
is_dir: false,
|
||||||
|
size: 10,
|
||||||
|
created_at: 2000,
|
||||||
|
modified_at: 2000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
};
|
||||||
|
inner.manifest.inodes.insert(id, inode);
|
||||||
|
inner.index_add_child(1, id);
|
||||||
|
}
|
||||||
|
inner.mark_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
cfs.sync_manifest().expect("Sync 2 pages");
|
||||||
|
let page_blocks = {
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
inner.manifest.page_block_indices.clone()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
page_blocks.len() >= 2,
|
||||||
|
"Benötigt mindestens 2 Seiten, hat: {}",
|
||||||
|
page_blocks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let second_page_block = page_blocks[1];
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// 2. Gezielte Beschädigung: Zweite Seite in SQLite mit Rauschen überschreiben
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&env.path).unwrap();
|
||||||
|
let mut noise = vec![0u8; 500];
|
||||||
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = ?3",
|
||||||
|
rusqlite::params![noise, env.carrier_node_id, second_page_block],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Neu laden: Darf NICHT abbrechen (Fail-Soft), sondern überspringt die defekte Seite
|
||||||
|
let cfs_reloaded = load_carrier_fs(&env);
|
||||||
|
assert_eq!(
|
||||||
|
cfs_reloaded.corrupted_pages(),
|
||||||
|
1,
|
||||||
|
"Muss genau 1 beschädigte Seite protokollieren"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wurzelverzeichnis '/' muss intakt sein
|
||||||
|
assert!(cfs_reloaded.resolve_path("/").is_some());
|
||||||
|
|
||||||
|
// Einträge der ersten, unbeschädigten Seite müssen weiterhin vollständig auffindbar sein
|
||||||
|
let alpha_0 = cfs_reloaded.resolve_path(&format!("/alpha_0000_{}", "a".repeat(800)));
|
||||||
|
assert!(
|
||||||
|
alpha_0.is_some(),
|
||||||
|
"Dateien aus der intakten Seite 0 müssen lesbar bleiben"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4. Index-Konsistenztest (D-02): Operationsfolge gegen frisch aufgebauten Referenzindex abgleichen
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_children_index_consistency() {
|
||||||
|
let env = setup_carrier_env("children_consistency", 10);
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
// 1. Verzeichnisse anlegen
|
||||||
|
cfs.create_dir(&DavPath::new("/docs").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cfs.create_dir(&DavPath::new("/docs/work").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cfs.create_dir(&DavPath::new("/photos").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 2. Dateien anlegen
|
||||||
|
let opt = OpenOptions {
|
||||||
|
create_new: true,
|
||||||
|
write: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut f1 = cfs
|
||||||
|
.open(&DavPath::new("/docs/report.txt").unwrap(), opt.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
f1.write_bytes(Bytes::from_static(b"report")).await.unwrap();
|
||||||
|
f1.flush().await.unwrap();
|
||||||
|
drop(f1);
|
||||||
|
|
||||||
|
let mut f2 = cfs
|
||||||
|
.open(&DavPath::new("/docs/work/notes.txt").unwrap(), opt.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
f2.write_bytes(Bytes::from_static(b"notes")).await.unwrap();
|
||||||
|
f2.flush().await.unwrap();
|
||||||
|
drop(f2);
|
||||||
|
|
||||||
|
let mut f3 = cfs
|
||||||
|
.open(&DavPath::new("/photos/pic.jpg").unwrap(), opt.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
f3.write_bytes(Bytes::from_static(b"pic")).await.unwrap();
|
||||||
|
f3.flush().await.unwrap();
|
||||||
|
drop(f3);
|
||||||
|
|
||||||
|
// 3. Verschieben/Umbenennen
|
||||||
|
cfs.rename(
|
||||||
|
&DavPath::new("/docs/work/notes.txt").unwrap(),
|
||||||
|
&DavPath::new("/photos/notes_moved.txt").unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 4. Datei löschen
|
||||||
|
cfs.remove_file(&DavPath::new("/docs/report.txt").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 5. Verzeichnis löschen
|
||||||
|
cfs.remove_dir(&DavPath::new("/docs/work").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 6. Abgleich: children_index gegen frisch aufgebauten Referenzindex prüfen
|
||||||
|
{
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
let reference_index = CarrierFsInner::build_children_index(&inner.manifest.inodes);
|
||||||
|
|
||||||
|
for (parent_id, ref_children) in &reference_index {
|
||||||
|
let actual_children = inner.children_index.get(parent_id);
|
||||||
|
assert!(
|
||||||
|
actual_children.is_some(),
|
||||||
|
"Parent {} fehlt im children_index",
|
||||||
|
parent_id
|
||||||
|
);
|
||||||
|
let mut ref_sorted = ref_children.clone();
|
||||||
|
ref_sorted.sort();
|
||||||
|
let mut act_sorted = actual_children.unwrap().clone();
|
||||||
|
act_sorted.sort();
|
||||||
|
assert_eq!(
|
||||||
|
ref_sorted, act_sorted,
|
||||||
|
"Inkonsistenz der Kinder für Parent {}",
|
||||||
|
parent_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch die Umkehrung prüfen (keine Geister-Einträge)
|
||||||
|
for (parent_id, actual_children) in &inner.children_index {
|
||||||
|
if actual_children.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
reference_index.contains_key(parent_id),
|
||||||
|
"children_index enthält verwaisten Parent {}",
|
||||||
|
parent_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5. Performance-Sanity-Check: Pfadauflösung bei 5.000 Geschwistern muss sub-millisekündlich sein (D-02)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_secondary_index_path_resolution_speed() {
|
||||||
|
let env = setup_carrier_env("perf_resolution", 15);
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
cfs.create_dir(&DavPath::new("/big_dir").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let big_dir_node = cfs.resolve_path("/big_dir").expect("big_dir node");
|
||||||
|
|
||||||
|
// 5.000 Kindknoten in /big_dir anlegen
|
||||||
|
{
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
for i in 0..5_000 {
|
||||||
|
let id = inner.manifest.next_inode_id;
|
||||||
|
inner.manifest.next_inode_id += 1;
|
||||||
|
let inode = CarrierInode {
|
||||||
|
id,
|
||||||
|
parent_id: Some(big_dir_node.id),
|
||||||
|
name: format!("child_{:04}.txt", i),
|
||||||
|
is_dir: false,
|
||||||
|
size: 10,
|
||||||
|
created_at: 3000,
|
||||||
|
modified_at: 3000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
};
|
||||||
|
inner.manifest.inodes.insert(id, inode);
|
||||||
|
inner.index_add_child(big_dir_node.id, id);
|
||||||
|
}
|
||||||
|
inner.mark_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messung: Pfadauflösung für den letzten Eintrag
|
||||||
|
let start = Instant::now();
|
||||||
|
let target = cfs.resolve_path("/big_dir/child_4999.txt");
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
assert!(target.is_some());
|
||||||
|
assert_eq!(target.unwrap().name, "child_4999.txt");
|
||||||
|
|
||||||
|
// Der Sekundärindex muss den Eintrag in unter 50 Millisekunden finden (typisch < 0.2 ms, O(1) statt O(N))
|
||||||
|
assert!(
|
||||||
|
elapsed.as_millis() < 50,
|
||||||
|
"Pfadauflösung dauerte zu lange: {:?}",
|
||||||
|
elapsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 6. Platzmangel-Migrationstest: Migration eines Containers ohne freie Blöcke bricht sauber ab (D-03)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_out_of_space_migration_aborts_cleanly() {
|
||||||
|
let env = setup_carrier_env("migration_no_space", 5);
|
||||||
|
|
||||||
|
// V1-Manifest mit 0 freien Blöcken (free_blocks ist leer)
|
||||||
|
let mut v1_manifest = CarrierManifest {
|
||||||
|
magic: *CARRIER_MAGIC,
|
||||||
|
version: CARRIER_VERSION,
|
||||||
|
manifest_generation: 1,
|
||||||
|
total_blocks: 2,
|
||||||
|
free_blocks: Vec::new(), // Keine freien Blöcke!
|
||||||
|
next_inode_id: 2,
|
||||||
|
page_block_indices: Vec::new(),
|
||||||
|
inodes: HashMap::new(),
|
||||||
|
};
|
||||||
|
v1_manifest.inodes.insert(
|
||||||
|
1,
|
||||||
|
CarrierInode {
|
||||||
|
id: 1,
|
||||||
|
parent_id: None,
|
||||||
|
name: String::new(),
|
||||||
|
is_dir: true,
|
||||||
|
size: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
modified_at: 1000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
|
||||||
|
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
|
||||||
|
let dek_inner = env.auth_hidden.dek();
|
||||||
|
write_carrier_block(
|
||||||
|
&env.db,
|
||||||
|
env.carrier_node_id,
|
||||||
|
0,
|
||||||
|
&dek_outer,
|
||||||
|
&dek_inner,
|
||||||
|
&v1_bytes,
|
||||||
|
env.auth_hidden.version(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_carrier_block(
|
||||||
|
&env.db,
|
||||||
|
env.carrier_node_id,
|
||||||
|
1,
|
||||||
|
&dek_outer,
|
||||||
|
&dek_inner,
|
||||||
|
&v1_bytes,
|
||||||
|
env.auth_hidden.version(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
// Versuch zu speichern muss mit klarer Fehlermeldung fehlschlagen
|
||||||
|
let res = {
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
inner.mark_dirty();
|
||||||
|
inner.save_manifest()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(res.is_err());
|
||||||
|
let err_msg = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Nicht genügend freie Blöcke"),
|
||||||
|
"Fehlermeldung muss auf Speichermangel hinweisen: {}",
|
||||||
|
err_msg
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// Trägerdatei muss weiterhin intakt als V1 ladbar sein
|
||||||
|
let cfs_recheck = load_carrier_fs(&env);
|
||||||
|
{
|
||||||
|
let inner = cfs_recheck.inner.lock().unwrap();
|
||||||
|
assert_eq!(inner.manifest.version, CARRIER_VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 7. Seitenfreigabe (D-05): Überzählige Seitenblöcke werden bei Schrumpfung geschreddert und freigegeben
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_page_block_reclaiming_on_shrink() {
|
||||||
|
let env = setup_carrier_env("page_reclaiming", 25);
|
||||||
|
let cfs = load_carrier_fs(&env);
|
||||||
|
|
||||||
|
let initial_free = {
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
inner.manifest.free_blocks.len()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Viele Inodes erzeugen, sodass mindestens 3 Seiten belegt werden
|
||||||
|
let mut added_ids = Vec::new();
|
||||||
|
{
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
for i in 0..2500 {
|
||||||
|
let id = inner.manifest.next_inode_id;
|
||||||
|
inner.manifest.next_inode_id += 1;
|
||||||
|
added_ids.push(id);
|
||||||
|
let inode = CarrierInode {
|
||||||
|
id,
|
||||||
|
parent_id: Some(1),
|
||||||
|
name: format!("large_item_{:04}_{}", i, "x".repeat(800)),
|
||||||
|
is_dir: false,
|
||||||
|
size: 10,
|
||||||
|
created_at: 4000,
|
||||||
|
modified_at: 4000,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
};
|
||||||
|
inner.manifest.inodes.insert(id, inode);
|
||||||
|
inner.index_add_child(1, id);
|
||||||
|
}
|
||||||
|
inner.mark_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
cfs.sync_manifest().expect("Sync 3 pages");
|
||||||
|
let pages_before = cfs.page_count();
|
||||||
|
assert!(
|
||||||
|
pages_before >= 3,
|
||||||
|
"Muss mindestens 3 Seiten belegen, hat: {}",
|
||||||
|
pages_before
|
||||||
|
);
|
||||||
|
|
||||||
|
let free_after_expand = {
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
inner.manifest.free_blocks.len()
|
||||||
|
};
|
||||||
|
assert_eq!(free_after_expand, initial_free - (pages_before - 1));
|
||||||
|
|
||||||
|
// 2. Fast alle Inodes entfernen, sodass nur noch 1 Seite benötigt wird
|
||||||
|
{
|
||||||
|
let mut inner = cfs.inner.lock().unwrap();
|
||||||
|
for id in added_ids {
|
||||||
|
inner.manifest.inodes.remove(&id);
|
||||||
|
inner.index_remove_child(1, id);
|
||||||
|
}
|
||||||
|
inner.mark_dirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
cfs.sync_manifest().expect("Sync after shrink");
|
||||||
|
|
||||||
|
// 3. Verifizieren: page_count ist wieder 1, freie Blöcke wurden zurückgegeben
|
||||||
|
let pages_after = cfs.page_count();
|
||||||
|
assert_eq!(
|
||||||
|
pages_after, 1,
|
||||||
|
"Nach Schrumpfung darf nur noch 1 Seite belegt sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
let free_after_shrink = {
|
||||||
|
let inner = cfs.inner.lock().unwrap();
|
||||||
|
inner.manifest.free_blocks.len()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
free_after_shrink, initial_free,
|
||||||
|
"Alle überzähligen Seitenblöcke müssen an free_blocks zurückgegeben worden sein"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(cfs);
|
||||||
|
|
||||||
|
// Trägerdatei neu laden und Integrität prüfen
|
||||||
|
let cfs_reloaded = load_carrier_fs(&env);
|
||||||
|
assert_eq!(cfs_reloaded.page_count(), 1);
|
||||||
|
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
|
||||||
|
assert!(cfs_reloaded.resolve_path("/").is_some());
|
||||||
|
}
|
||||||
+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,240 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mount_rejects_tampered_metadata_mac() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf = temp_dir.join(format!(
|
||||||
|
"test_mount_tampered_mac_{}.sanctum",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
if container_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "TestTamperedMacPassword2026!";
|
||||||
|
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(&container_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
|
.unwrap();
|
||||||
|
db.set_active_slot_and_dek(0, dek.clone());
|
||||||
|
|
||||||
|
// Erstelle einen Testknoten
|
||||||
|
let node = db.create_node(1, "original.txt", false).unwrap();
|
||||||
|
db.update_metadata_mac().unwrap();
|
||||||
|
|
||||||
|
// Vor Manipulation: verify_metadata_mac muss erfolgreich sein
|
||||||
|
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||||
|
|
||||||
|
// Manipuliere SQLite-Metadaten direkt (Böswilliger Angreifer)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE nodes SET name = 'hacked.exe' WHERE id = ?1",
|
||||||
|
[node.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Manipulation: Direkte Verifikation meldet 'false' (Integritätsbruch)
|
||||||
|
assert!(!db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// Aufruf von mount_container muss fail-closed abbrechen
|
||||||
|
let auth =
|
||||||
|
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
|
||||||
|
let res = sanctum::mount::mount_container(
|
||||||
|
&container_path,
|
||||||
|
'Z',
|
||||||
|
None,
|
||||||
|
Some(18943),
|
||||||
|
auth,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Mount mit manipulierten Metadaten muss fehlschlagen!"
|
||||||
|
);
|
||||||
|
let err_msg = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Metadaten-MAC-Verifikation fehlgeschlagen") || err_msg.contains("K-01"),
|
||||||
|
"Erwartete Fehlermeldung zu K-01 Metadaten-MAC, erhalten: {}",
|
||||||
|
err_msg
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,3 +127,200 @@ fn test_storage_create_and_rename_reject_invalid_names() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_carrier_fs_rejects_invalid_names() {
|
||||||
|
use dav_server::davpath::DavPath;
|
||||||
|
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sanctum::carrier::CarrierFs;
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
||||||
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let id: u64 = OsRng.next_u64();
|
||||||
|
path.push(format!("sanctum_test_carrier_pathval_{}.sanctum", id));
|
||||||
|
if path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let carrier_size_bytes = 10 * 1024 * 1024;
|
||||||
|
let carrier_name = "system_backup.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");
|
||||||
|
|
||||||
|
let cfs = CarrierFs::load(
|
||||||
|
db.clone(),
|
||||||
|
carrier_node_id,
|
||||||
|
Arc::new(dek_0),
|
||||||
|
Arc::new(dek_1),
|
||||||
|
3,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("Load CarrierFs");
|
||||||
|
|
||||||
|
// 1. create_dir mit ungültigem Namen (Windows-Reservierung CON, PRN)
|
||||||
|
let p_con = DavPath::new("/CON").unwrap();
|
||||||
|
assert_eq!(cfs.create_dir(&p_con).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
let p_aux = DavPath::new("/aux.txt").unwrap();
|
||||||
|
assert_eq!(cfs.create_dir(&p_aux).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
// 2. open (create) mit ungültigem Namen
|
||||||
|
let p_nul = DavPath::new("/NUL").unwrap();
|
||||||
|
let mut opt_create = OpenOptions::default();
|
||||||
|
opt_create.create = true;
|
||||||
|
opt_create.write = true;
|
||||||
|
assert_eq!(
|
||||||
|
cfs.open(&p_nul, opt_create.clone()).await.err(),
|
||||||
|
Some(FsError::Forbidden)
|
||||||
|
);
|
||||||
|
|
||||||
|
let p_ctrl = DavPath::new("/prn.pdf").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfs.open(&p_ctrl, opt_create.clone()).await.err(),
|
||||||
|
Some(FsError::Forbidden)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Gültige Datei erstellen
|
||||||
|
let p_valid = DavPath::new("/valid.txt").unwrap();
|
||||||
|
let file = cfs.open(&p_valid, opt_create).await;
|
||||||
|
assert!(file.is_ok(), "Valid file should be created");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// 4. rename mit ungültigem Zielnamen
|
||||||
|
let p_com1 = DavPath::new("/COM1.txt").unwrap();
|
||||||
|
assert_eq!(cfs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
// 5. copy mit ungültigem Zielnamen
|
||||||
|
let p_lpt1 = DavPath::new("/LPT1").unwrap();
|
||||||
|
assert_eq!(cfs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_vfs_rejects_invalid_names() {
|
||||||
|
use dav_server::davpath::DavPath;
|
||||||
|
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
||||||
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
use sanctum::vfs::SanctumFs;
|
||||||
|
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let id: u64 = OsRng.next_u64();
|
||||||
|
path.push(format!("sanctum_test_vfs_pathval_{}.sanctum", id));
|
||||||
|
if path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "TestPassword1234!";
|
||||||
|
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, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag)
|
||||||
|
.unwrap();
|
||||||
|
db.set_active_slot_and_dek(0, dek.clone());
|
||||||
|
|
||||||
|
let fs = SanctumFs::new(db.clone(), dek, FORMAT_VERSION);
|
||||||
|
|
||||||
|
// 1. create_dir mit ungültigem Namen
|
||||||
|
let p_con = DavPath::new("/CON").unwrap();
|
||||||
|
assert_eq!(fs.create_dir(&p_con).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
let p_aux = DavPath::new("/aux.txt").unwrap();
|
||||||
|
assert_eq!(fs.create_dir(&p_aux).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
// 2. open (create) mit ungültigem Namen
|
||||||
|
let p_nul = DavPath::new("/NUL").unwrap();
|
||||||
|
let mut opt_create = OpenOptions::default();
|
||||||
|
opt_create.create = true;
|
||||||
|
opt_create.write = true;
|
||||||
|
assert_eq!(
|
||||||
|
fs.open(&p_nul, opt_create.clone()).await.err(),
|
||||||
|
Some(FsError::Forbidden)
|
||||||
|
);
|
||||||
|
|
||||||
|
let p_prn = DavPath::new("/prn.pdf").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs.open(&p_prn, opt_create.clone()).await.err(),
|
||||||
|
Some(FsError::Forbidden)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Gültige Datei erstellen
|
||||||
|
let p_valid = DavPath::new("/valid.txt").unwrap();
|
||||||
|
let file = fs.open(&p_valid, opt_create).await;
|
||||||
|
assert!(file.is_ok(), "Valid file should be created");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// 4. rename mit ungültigem Zielnamen
|
||||||
|
let p_com1 = DavPath::new("/COM1.txt").unwrap();
|
||||||
|
assert_eq!(fs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
// 5. copy mit ungültigem Zielnamen
|
||||||
|
let p_lpt1 = DavPath::new("/LPT1").unwrap();
|
||||||
|
assert_eq!(fs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
|
||||||
|
|
||||||
|
drop(fs);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&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