30 Commits
Author SHA1 Message Date
harald 436790abf0 feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
2026-09-18 19:12:43 +02:00
harald 80a3bd1911 docs: update README.md with sanctum sync and in-place upgrade documentation 2026-09-16 13:46:35 +02:00
harald 2da1ea55a9 chore(release): update scoop and winget manifests for v0.6.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-16 12:40:37 +02:00
harald 2e4cf1cef0 feat(upgrade): implement in-place self-upgrade command and bump version to v0.6.0 2026-09-16 12:33:57 +02:00
harald 1048253497 chore(release): update scoop and winget manifests for v0.5.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-16 10:44:18 +02:00
harald aa65d96434 feat(sync): add rsync-like sync command with dry-run and bump version to v0.5.0 2026-09-16 10:40:38 +02:00
harald 3f3f8f2730 chore(packages): update scoop and winget manifests to v0.4.1
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 14:11:51 +02:00
harald 650cfc7cd4 chore(release): bump version to v0.4.1
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 14:02:02 +02:00
harald 9d1a4d3ad7 build: preserve dist directory and merge SHA256 sums in package-release.ps1
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 13:36:59 +02:00
harald aa25719c0d fix(linux): prevent premature unmount caused by closed channel triggers in select!
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-14 13:27:10 +02:00
harald 34378998c4 feat(linux): add --mount-point CLI option and platform-aware mount messages
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-12 14:36:43 +02:00
harald 4ef0c414fa feat(linux): add static musl x86_64 build, packaging, and Freedesktop integration
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:45:04 +02:00
harald 1f3afb1e52 fix(scripts): correct URL variable interpolation and add error check in publish script
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:05:15 +02:00
harald 5f2040f8bf chore(release): bump version to 0.4.0 and update manifests
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 17:02:05 +02:00
harald da2ddbd6ab feat(distribution): add Scoop/Winget manifests, INSTALL.md, and Unix mount integration 2026-09-10 16:51:24 +02:00
harald 46f976b353 feat(cross-platform): add QUICKSTART.md, live crash test, and platform abstraction module 2026-09-10 16:31:57 +02:00
harald 6cb48f55d9 docs(compliance): add LEGAL.md, THIRD_PARTY_LICENSES.md, and update packaging
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 15:14:51 +02:00
harald 7ed0f51fb1 chore(release): bump version to 0.3.1 and update release notes
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 14:08:48 +02:00
harald dae0b3b7c3 feat(resilience): implement CHAOS-01 to CHAOS-03 crash consistency and input guards
- CHAOS-01: write chunk and update node size atomically via write_chunk_and_update_size in a single SQLite transaction
- CHAOS-02: reject null-bytes and ASCII control characters in VFS paths and node names via validate_path_safety
- CHAOS-03: invalidate in-memory chunk cache on I/O and disk-full errors to prevent drop failure cascades
2026-09-10 13:58:39 +02:00
harald 541190cff4 feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
2026-09-10 13:27:01 +02:00
harald 99813ae2a3 feat(security): add Win32 VirtualLock memory protection, CFA error diagnostics, and ShellBag OpSec 2026-09-10 12:27:57 +02:00
harald 90cf4927af fix(release): normalize file paths for curl upload and upload standalone binary 2026-09-09 22:21:34 +02:00
harald abf395627a chore: release v0.3.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-09 22:00:04 +02:00
harald fcd59dfe68 security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection 2026-09-09 21:54:05 +02:00
harald a8fdfc674e fix(sre): implement PRR-01 stale mount self-healing and PRR-02 win32 console close handler 2026-09-09 21:45:35 +02:00
harald 70d03df676 fix(security): implement P3 high-entropy carrier default and P5 manifest capacity guard 2026-09-09 21:33:01 +02:00
harald 771c08ff0f fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization 2026-09-09 21:24:07 +02:00
harald 98bfac718f fix(core): resolve all 12 adversarial review findings
- Zeroize passwords in CLI prompts, handlers, and mount authentication
- Preserve carrier_node_id when recovering Slot 0 via recovery key
- Add --slot parameter to restore-header for targeted slot recovery
- Implement online_backup and restore_from_backup using SQLite Online Backup API
- Expose 'sanctum backup' and 'sanctum restore' CLI subcommands
- Fix inactivity auto-lock by removing touch() from PROPFIND metadata/read_dir
- Support O_APPEND by setting file cursor to file size on handle creation
- Prevent data loss by implementing Drop for CarrierFile to flush dirty blocks
- Optimize CSPRNG padding to only fill unwritten slack space
- Batch carrier initialization in 500-block transactions to prevent UI/CLI freeze
- Enforce 64-byte savings threshold for LZ4 compression
- Add WebClient service diagnostic hint for Windows net use mount errors
- Add unit and integration tests covering all new features
2026-09-09 21:02:21 +02:00
harald 030ce6a1e5 feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability) 2026-09-09 20:22:00 +02:00
harald b8e4dcb614 feat(security): implement Phase 1 of Plausible Deniability hardening 2026-09-09 19:55:34 +02:00
39 changed files with 9551 additions and 742 deletions
+2
View File
@@ -6,3 +6,5 @@
.env.* .env.*
*.token *.token
.token .token
*.key
sanctum-release.key
+160
View File
@@ -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.7.0] - 2026-09-18
### Security Hardening Release
Dieses Release behebt alle 11 identifizierten Sicherheitsprobleme (S-01 bis S-11) aus dem Sicherheits-Review von v0.6.0 umfassend und implementiert ein striktes Fail-Closed-Design.
#### Security & Architecture
- **S-01: Ehrliche Neuklassifizierung des Dual-Vault (Second Safe)**:
- Beseitigung aller unhaltbaren Behauptungen über "Plausible Deniability", "RIPA-49-Konformität" und "mathematische Ununterscheidbarkeit".
- Klarstellung in Dokumentation und CLI: Das Dual-Vault-Design (Modell A mit Alibi-Carrier) bietet Schutz vor Schulterblick und erzwungener Herausgabe im Alltag, ist jedoch durch forensische Entropieanalyse der Trägerdatei detektierbar und stellt keinen rechtlichen Schutz gegen Beschlagnahme dar.
- **S-02: Minisign-Signaturprüfung & Härtung des Update-Mechanismus**:
- Integration von `minisign-verify` zur kryptografischen Prüfung der `SHA256SUMS.txt.minisig` gegen den offiziellen Ed25519 Release-Schlüssel (`RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU`) vor jedem Binary-Austausch.
- Sichere Erstellung von Zwischendateien mittels `tempfile::Builder` mit restriktiven Dateirechten und RAII-Bereinigung im Fehlerfall.
- Sperrung von Drittanbieter-URLs: Inoffizielle Server erfordern zwingend das Flag `--insecure-url` und eine interaktive Benutzerbestätigung (nicht durch `--yes` umgehbar).
- **S-03: Carrier-Schutz vor Beschädigung und Korruption**:
- Einführung der Datenbankspalte `is_carrier INTEGER NOT NULL DEFAULT 0` in der `nodes`-Tabelle zur präzisen Identifikation der Alibi-Trägerdatei ohne False Positives auf Benutzerdateien.
- Fail-Closed Schutzschranke `assert_not_carrier`: Löschung (`delete_node`), Kürzung (`truncate_chunks_after`), Umbenennung (`rename_node_in_vault`) oder VFS-Überschreibung der Trägerdatei sowie das Löschen enthaltender Ordner wird strikt verboten.
- Schutz der Trägerdatei bei `sanctum sync`: Die Alibi-Datei wird bei `--delete` (`delete_orphans_in_vault`) niemals entfernt.
- **S-04: Schutz vor nicht vertrauenswürdigen Containern & KDF-DoS**:
- `Database::open` prüft vor Ausführung von Pragmas, VACUUM oder Schema-Upgrades, ob fremde SQLite-Datenbanken vorliegen und lehnt diese sofort fail-closed ab.
- Strikte Bereichsprüfung aller KDF-Parameter (`validate_kdf_params`): Memory Cost 19 MiB bis 256 MiB, Time Cost 2 bis 8, Parallelität 1 bis 8.
- Strikte Validierung aller Header-Slot-Feldlängen (Salt 16B, Nonce 12B, Tag 16B, Wrapped DEK 40B/32B in Slot 0, 72B/64B/32B in Slot 1).
- **S-05: Eliminierung unkontrollierter Panics in der Kryptografie**:
- Beseitigung aller potenziellen Panics in `encrypt_node_name` und `decrypt_chunk` (LZ4-Größenvalidierung und unkomprimierte Chunks geben nun kontrollierte `Result`-Fehler zurück).
- Umstellung des Release-Profils in `Cargo.toml` auf `panic = "unwind"` zur Vermeidung von Process-Aborts bei unerwarteten Fehlern.
- **S-06: WebDAV-Loopback-Härtung & Unmount-Bereinigung**:
- Robuste Host-Header-Prüfung in `is_loopback_host` (exakter Abgleich gegen `127.0.0.1`, `localhost`, `[::1]`, `::1` samt Port-Validierung) zur Abwehr von DNS-Rebinding- und Host-Header-Injection-Angriffen.
- Beseitigung der Session-Token-Ausgabe im Terminal.
- Zwingende Überprüfung und Löschung von temporären SQLite `-wal` und `-shm` Dateien nach Aushängen des Containers.
- **S-07: Härtung der KDF-Parameter & Passwort-Validierung**:
- Anhebung der KDF-Untergrenzen auf RFC 9106 Mindeststandards (Memory 19.456 KiB, Time Cost 2).
- Mindestpasswortlänge von 12 Zeichen bei `init` und `passwd`.
- Verbot identischer Passwörter oder Präfix-Kollisionen zwischen Standard- und Hidden-Vault.
- **S-08: Path-Traversal-Schutz & Dateinamen-Validierung**:
- Umfassende Dateinamenprüfung `validate_node_name` in `sanctum sync` und VFS: Verbot von Pfadtrennern (`/`, `\`), Null-Bytes, Steuerzeichen (< 0x20), `.` / `..` sowie reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
- Strenge Pfadausbruchsprüfung bei `sync pull`.
- **S-09: Sichere Einlesung von Wiederherstellungsschlüsseln**:
- CLI-String-Übergabe von 24-Wort Notfallschlüsseln via `--recovery-key <PHRASE>` ist veraltet (Deprecation Warning).
- Unterstützung für interaktive maskierte Eingabe (kein History-Leak), Stdin (`--recovery-key -`) und Umgebungsvariable `SANCTUM_RECOVERY_KEY`.
- **S-10: SHA-256 Checksummenprüfung in Sync & WAL-Sicherheit**:
- Echte SHA-256 Hash-Vergleiche bei `sanctum sync --checksum`.
- Atomare SQLite-Transaktionen und garantierte WAL-Bereinigung.
- **S-11: Sichere Trennung bei Dateinamen-Entschlüsselung**:
- Unsicherer statischer Fallback-Schlüssel (`b"SANCTUM_NODE_NAME"`) ist standardmäßig deaktiviert.
- Abwärtskompatibilität für Alttresore nur noch bei expliziter Angabe des globalen CLI-Flags `--legacy-names`.
## [0.6.0] - 2026-09-16
### Added
- **`sanctum upgrade` (Alias: `sanctum update`) - In-Place Self-Upgrade Engine**:
- Ermöglicht das direkte Aktualisieren der laufenden Sanctum-Binary ohne externe Paketmanager oder manuelle Download-Schritte.
- **Plattformunabhängiger In-Place-Austausch**: Löst das Windows-Dateilock-Problem (`ERROR_ACCESS_DENIED`) via `self-replace` (Umbenennen der laufenden Executable und verzögerte Bereinigung). Auf Linux/Unix wird ein sauberer atomarer Tausch mit Ausführungsrechten (`0o755`) durchgeführt.
- **Kryptografische Integritätsprüfung**: Vor dem Austausch wird jedes heruntergeladene Binary zwingend gegen die offizielle `SHA256SUMS.txt` des Gitea-Releases via SHA-256 verifiziert. Bei Abweichungen bleibt die bestehende Installation vollständig unberührt.
- **Prüfmodus (`--check` / `-c`)**: Schneller Abgleich mit der Gitea-Releases-API zur Feststellung, ob ein Update vorliegt, ohne Downloads durchzuführen.
- **Automatisierungsmodus (`--yes` / `-y`)**: Führt das Upgrade ohne interaktive Bestätigung durch ideal für geplante Aufgaben und Skripte.
- **Force-Option (`--force` / `-f`)**: Erlaubt das Erzwingen einer Neuinstallation / Aktualisierung auch bei gleicher Version oder erkannter Paketmanager-Umgebung.
- **Paketmanager-Erkennung**: Erkennt automatisch, ob Sanctum über Scoop oder Winget ausgeführt wird, und empfiehlt den entsprechenden Paketmanager-Befehl (`scoop update sanctum` bzw. `winget upgrade HaraldPansi.Sanctum`).
- **Testsuite**:
- `tests/upgrade_test.rs`: 5 neue Tests zur Verifikation von Checksum-Parsing, SemVer-Vergleich, Paketmanager-Erkennung, SHA-256 Hash-Validierung und Gitea Release API-JSON-Deserialisierung.
## [0.5.0] - 2026-09-16
### Added
- **`sanctum sync` - Native rsync-ähnliche Synchronisation**:
- Bidirektionale Synchronisation (`Push`: Host -> Tresor, `Pull`: Tresor -> Host) direkt auf SQLite- und VFS-Ebene unter vollständiger Umgehung des WebDAV-Layers.
- **Beseitigung des 4-GB-Dateilimits**: Löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher systembedingt auf 32-Bit Dateigrößen (~4 GB - 1 Byte / Fehler `0x800700DF`) beschränkt ist. Sanctum streamt nun Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+) in 1 MB Chunks mit AES-256-GCM direkt in/aus den Tresor.
- **Dry-Run-Simulation (`--dry-run` / `-n`)**: Ermöglicht die risikolose Vorschau aller auszuführenden Aktionen (Hinzufügen, Aktualisieren, Löschen, Überspringen) samt Datenmengen-Berechnung, ohne tatsächliche Änderungen am Ziel durchzuführen.
- **Spiegelung & Verwaiste Dateien bereinigen (`--delete`)**: Unterstützt das automatische Löschen von Dateien im Ziel, die in der Quelle nicht mehr existieren, für exakte Ordnerspiegelungen.
- **Inhaltsbasierte Hash-Prüfung (`--checksum` / `-c`)**: Optionaler byteweiser SHA-256 Inhaltsvergleich anstelle des schnellen Größen-/mtime-Prüfverfahrens.
- **Ausschlussmuster (`--exclude <PATTERN>`)**: Unterstützung für flexible Glob-Muster zum Ignorieren temporärer Dateien, Downloads oder System-Metadaten (z. B. `*.crdownload`, `*.tmp`, `Thumbs.db`).
- **Quiet-Modus (`--quiet` / `-q`)**: Ermöglicht lautlose Ausführung für Skripte und geplante Aufgaben mit knapper Zusammenfassungsstatistik.
- **Metadaten-Konservierung**: Erhaltung und Übertragung präziser Datei-Modifikationszeitstempel (`mtime`) in beide Richtungen.
- **Integrationstests**:
- `tests/sync_test.rs`: Umfassende automatisierte Testabdeckung für Push/Pull, Multi-Chunk Großdateien, Fast-Delta-Check, Dry-Run-Invarianz, `--delete` und `--exclude`.
## [0.4.1] - 2026-09-14
### Fixed
- **Linux Mount-Stabilität & Signal-Handling**:
- Behebung eines sofortigen automatischen Aushängens direkt nach erfolgreichem Mount unter Linux: Windows-spezifische Kontrollkanäle (Console-Close, Session-Lock, System-Tray) wurden auf Nicht-Windows-Systemen sofort geschlossen, wodurch `tokio::select!` über `None` vorzeitig ausgelöst wurde.
- Plattform-spezifische Aufteilung der `tokio::select!`-Ereignisschleife: Unter Linux/Unix wird nun sauber auf POSIX-Terminierungssignale (`SIGINT`, `SIGTERM`, `SIGHUP`) gelauscht.
- Defensives Pattern-Matching mit `Some(()) = rx.recv()` stellt sicher, dass geschlossene Kanäle keine unberechtigten Shutdowns mehr auslösen können.
- Automatisches Öffnen via `xdg-open` auf Linux vorerst unterdrückt.
## [0.4.0] - 2026-09-10
### Added
- **Distribution & Package Ecosystem**:
- Offizielles Scoop-Manifest (`packages/scoop/sanctum.json`) für One-Click CLI-Installation und nahtlose Upgrades unter Windows.
- Windows Package Manager (Winget) Manifest (`packages/winget/HaraldPansi.Sanctum.yaml`) zur systemweiten Paketverwaltung.
- Vollständiger Installations-Leitfaden (`INSTALL.md`) für Scoop, Winget, Standalone-Binaries und `cargo install`.
- **Cross-Platform Architektur (Linux & macOS Vorbereitung)**:
- Neues Plattform-Abstraktionsmodul `src/platform/mod.rs` für OS-unabhängiges Speicherschutz- und Mount-Handling.
- POSIX-Memory-Hardening mit `mlock` und `munlock` Systemaufrufen für Unix-Systeme.
- Plattformübergreifendes Datei- und Ordneröffnen (`explorer.exe` unter Windows, `xdg-open` unter Linux, `open` unter macOS).
- Native WebDAV-VFS-Einbindung für Linux GNOME/KDE via `gio mount`.
- **Chaos Engineering & Live-Crash-Resilienz**:
- `tests/live_crash_resilience_test.rs`: Umfassende automatisierte Stresstests mit Multi-Worker Schreibabbrüchen, asynchronen Panic-Simulationen und Validierung der SQLite WAL-Konsistenz (Null Korruption).
- **Legal, Compliance & Software Bill of Materials (SBOM)**:
- `LEGAL.md`: Rechtliche Leitlinien, US EAR § 742.15(b) & EU Dual-Use Exportkontroll-Klassifizierung, DSGVO Art. 25 Zero-Data-Erklärung und Haftungsausschluss nach § 521 BGB.
- `THIRD_PARTY_LICENSES.md`: Lückenloses Lizenzaudit aller 231 Abhängigkeiten (100% permissive Lizenzen, 0% Copyleft) zur strikten Compliance mit Apache-2.0 Section 4.
- Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das offizielle Release-Distributionsarchiv (`package-release.ps1`).
- **Statisches Linux Single-Binary (`x86_64-unknown-linux-musl`)**:
- 100% statisch gelinktes Linux-Binary ohne GLIBC-Abhängigkeiten via Zig-Cross-Toolchain.
- Neues Linux-Distributionsarchiv `sanctum-v0.4.0-linux-x86_64.tar.gz` inklusive Dokumentation und Prüfsummen.
- Native Freedesktop `.desktop`-Datei und MIME-Type-Integration (`application/x-sanctum`) via `sanctum register`.
- **OpSec & Disaster Recovery UX**:
- `QUICKSTART.md`: Druckbare 24-Wörter BIP-39 Notfallkarte zur sicheren analogen Verwahrung des Notfallschlüssels (Air-Gapped Vault Paper Backup).
## [0.3.1] - 2026-09-10
### Added
- **Human Factors & OpSec-UX (HF-01 bis HF-04)**:
- Maskierte interaktive Notfallschlüssel-Eingabe: `--recovery-key` ohne CLI-Argument liest die 24 BIP-39-Wörter via `rpassword` ein, ohne Klartextspuren in der PowerShell-Historie (`ConsoleHost_history.txt`) zu hinterlassen.
- BIP-39 Resilienz & Tippfehler-Diagnose: Automatische String-Normalisierung (Kleinschreibung, Whitespace- und Satzzeichenbereinigung) sowie Levenshtein-Kandidatenvorschläge ($\le 2$ Distanz) bei Eingabefehlern.
- Stealth-Mount Modus (`--stealth` / `-s`): Vollständig lautloser Betrieb ohne Banner, Token-Ausgabe, Mount-Pfade oder Box-Rahmen.
- VFS Carrier-Schutz im Decoy-Vault: Umfassender Lösch-, Schreib-, Truncate- und Umbenennungsschutz der Alibi-Trägerdatei (`FsError::Forbidden`) bei Zugriff über Slot 0.
- **Chaos Engineering & Crash-Resilienz (CHAOS-01 bis CHAOS-03)**:
- Transaktionale Crash-Consistency (CHAOS-01): Bündelung von Chunk-Write und Dateigrößen-Aktualisierung (`write_chunk_and_update_size`) in einer einzigen atomaren SQLite-Transaktion (`tx.commit()`) für vollständige Resilienz gegen plötzlichen Stromausfall.
- Path Fuzzing & Injection Guard (CHAOS-02): Strikte Validierung aller VFS-Pfade und Knotennamen gegen Null-Bytes (`\0`) und ASCII-Steuerzeichen ($< 0x20$).
- Fault Recovery bei Disk Full (CHAOS-03): Sofortige Invalidierung des In-Memory-Chunk-Caches bei Schreibfehlern zur Vermeidung kaskadierender Drop-Panics bei vollem Zieldatenträger.
- **DFIR & Windows Host-Memory Hardening**:
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
### Changed
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
- Beseitigung verräterischer Konsolenausgaben (magenta hervorgehobene Hinweise auf Hidden Vault / Slot 1) und Harmonisierung des Explorer-Verhaltens für beide Safes.
- Vereinheitlichung der Ausgaben von `sanctum verify` und `sanctum recovery-key` zur Vermeidung von Rückschlüssen auf vorhandene Hidden-Vault-Slots.
- Interaktiver Assistent für `sanctum restore-header` bei Aufruf ohne CLI-Parameter.
## [0.3.0] - 2026-09-09
### Added
- **Modell A: Steganografischer Carrier & Plausible Deniability Phase 2**:
- Zwei-Schichten-AEAD (`DEK_0` + `DEK_1`) zur vollständigen Abwehr von Chunks-Accounting-Angriffen.
- Physische Dateigrößen-Invarianz: Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
- Virtuelles Carrier-Dateisystem (`CarrierFs`) mit reserviertem Block 0 für Manifest und Block-Allokationsbitmap.
- Schreib-, Lösch- und Umbenennungsschutz der Alibi-Trägerdatei im Decoy-Mount (`FsError::Forbidden`).
- **CLI & Disaster Recovery Erweiterungen**:
- `sanctum backup` & `sanctum restore`: Dedizierte CLI-Befehle für konsistente Online-Sicherungen.
- `sanctum restore-header --slot <0|1>`: Gezielte Wiederherstellung für Decoy- oder Hidden-Vault-Header.
- `sanctum init --carrier-file <NAME> --carrier-size <MB>`: Frei konfigurierbare Alibi-Trägerdatei mit forensischen Sicherheitsprüfungen.
- **SRE & Production Resilience**:
- Nativer Win32 `SetConsoleCtrlHandler`: Synchrones Aushängen und WAL-Checkpointing bei Schließen des Konsolenfensters (`CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, `CTRL_SHUTDOWN_EVENT`).
- Stale Mount Self-Healing: Automatische Erkennung und Bereinigung verwaister Windows-Netzlaufwerke (Systemfehler 85).
- Automatische Erkennung und Diagnosehilfe für den Windows `WebClient`-Dienst (Fehler 67).
- **Red Team & Threat Model Hardening**:
- WebDAV Loopback Protection: Dynamisches 128-Bit Session-Token im URL-Pfad (Schutz vor unprivilegierten lokalen Prozessen & CSRF).
- Anti-DNS-Rebinding & Anti-Spoofing: Strikte Fail-Closed Host-Header-Validierung.
- Anti-Slowloris & Connection Limiting: Begrenzung auf maximal 64 gleichzeitige WebDAV-Verbindungen und 15s Header-Read-Timeout.
- Decompression-Bomb-Schutz: Strikter 1-MB-Größen-Guard vor LZ4-Dekomprimierung zur Vermeidung von Speichererschöpfung (CWE-400).
- Verzeichnis-Hijacking-Schutz: Dynamische Bindung verschlüsselter Knotennamen an die `parent_id` via AEAD-AAD.
- Vollständige RAM-Zeroization: Schutz sensibler Daten im Heap via `zeroize::Zeroize` bei Puffer-Swaps und `Drop`.
### Changed
- Standardname der Trägerdatei auf forensisch plausiblen Typ `system_backup.dat` geändert.
- Heap- und Schreiboptimierung: Reduzierung der CSPRNG-Padding-Generierung auf den tatsächlichen Slack-Bereich (~30-fache Schreibbeschleunigung).
- Inaktivitäts-Tracking: `self.touch()` reagiert nur noch auf echte I/O-Interaktionen (`open`, read, write) und ignoriert passive Explorer-Hintergrundabfragen (`metadata`, `read_dir`).
- LZ4-Kompression: Schwellenwert von mindestens 64 Bytes Ersparnis eingeführt (`compressed.len() + 64 <= plaintext.len()`).
## [0.2.0] - 2026-09-08 ## [0.2.0] - 2026-09-08
### Added ### Added
Generated
+169 -1
View File
@@ -512,6 +512,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -1020,6 +1026,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -1090,6 +1102,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.3" version = "1.2.3"
@@ -1334,6 +1352,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.5.4" version = "7.5.4"
@@ -1369,6 +1401,54 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -1377,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.7.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -1393,16 +1473,22 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"lz4_flex", "lz4_flex",
"minisign-verify",
"rand", "rand",
"rpassword", "rpassword",
"rusqlite", "rusqlite",
"self-replace",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tray-item", "tray-item",
"ureq",
"zeroize", "zeroize",
] ]
@@ -1412,6 +1498,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand",
"tempfile",
"windows-sys 0.52.0",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -1466,6 +1569,17 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1564,6 +1678,19 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
@@ -1780,6 +1907,29 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1884,6 +2034,24 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
+11 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sanctum" name = "sanctum"
version = "0.2.0" version = "0.7.0"
edition = "2021" edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"] authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -18,7 +18,7 @@ argon2 = { version = "0.5", features = ["password-hash"] }
aes-gcm = { version = "0.10", features = ["zeroize"] } aes-gcm = { version = "0.10", features = ["zeroize"] }
rand = "0.8" rand = "0.8"
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] } zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled", "backup"] }
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
dav-server = { version = "0.11", default-features = false } dav-server = { version = "0.11", default-features = false }
hyper = { version = "1.4", features = ["server", "http1"] } hyper = { version = "1.4", features = ["server", "http1"] }
@@ -36,11 +36,19 @@ dyn-clone = "1.0"
lz4_flex = "0.11" lz4_flex = "0.11"
bip39 = { version = "2.2", features = ["zeroize"] } bip39 = { version = "2.2", features = ["zeroize"] }
hex = "0.4" hex = "0.4"
self-replace = "1.5"
ureq = { version = "2.10", default-features = false, features = ["json", "tls"] }
sha2 = "0.10"
semver = "1.0"
minisign-verify = "0.2"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
tray-item = "0.10" tray-item = "0.10"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "unwind"
strip = true strip = true
+94
View File
@@ -0,0 +1,94 @@
# Installation & Paketmanager-Guide für Sanctum 🛡️
Sanctum kann auf verschiedene Arten unter Windows 10/11 installiert und verwaltet werden. Es sind **keine Administratorrechte** erforderlich.
---
## 🚀 1. Installation via Scoop (Empfohlen für Entwickler)
Wenn du den Windows-Paketmanager [Scoop](https://scoop.sh/) nutzt, kannst du Sanctum direkt über das bereitgestellte Manifest installieren:
```powershell
# Direkt über die URL des Manifests installieren:
scoop install https://gitea.pansi.eu/harald/sanctum/raw/branch/main/packages/scoop/sanctum.json
```
Oder falls du ein lokales Repository geklont hast:
```powershell
scoop install .\packages\scoop\sanctum.json
```
Nach der Installation ist der Befehl `sanctum` sofort global in deiner PowerShell verfügbar.
---
## 🪟 2. Installation via Windows Package Manager (Winget)
Sanctum verfügt über ein standardkonformes Winget-Manifest (Schema 1.6.0).
### Lokale Installation via Manifest:
```powershell
winget install --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
```
Winget entpackt das portable Binary automatisch in das Benutzer-Anwendungsverzeichnis und verknüpft das Alias `sanctum`.
---
## 📦 3. Manuelle Installation (Standalone Portable)
1. Lade das aktuelle Release-Paket herunter:
* **URL:** [https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0](https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0)
* **Datei:** `sanctum-v0.4.0-windows-x86_64.zip`
2. Entpacke das Archiv in ein Verzeichnis deiner Wahl (z. B. `C:\Tools\Sanctum\`).
3. *(Optional)* Registriere das Windows Explorer Rechtsklick-Kontextmenü:
```powershell
.\sanctum.exe register
```
*(Erlaubt das Einhängen per Rechtsklick "In Sanctum öffnen" ohne Adminrechte).*
---
## 🐧 4. Installation unter Linux (x86_64)
Sanctum wird für Linux als **100 % statisches Single-Binary** (`x86_64-unknown-linux-musl`) ohne jegliche GLIBC- oder Bibliotheksabhängigkeiten bereitgestellt. Es läuft direkt auf Ubuntu, Debian, Alpine, Arch, Fedora und weiteren Distributionen:
1. Lade das Linux-Release herunter und installiere es:
```bash
curl -LO https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-linux-x86_64.tar.gz
tar -xzf sanctum-v0.4.0-linux-x86_64.tar.gz
chmod +x sanctum
sudo install -m 755 sanctum /usr/local/bin/sanctum
```
2. *(Optional)* Freedesktop-Integration einrichten (Doppelklick in GNOME / KDE / Thunar):
```bash
sanctum register
```
*Richtet die `.desktop`-Datei und den MIME-Typ `application/x-sanctum` im Userland (`~/.local/share`) ein.*
---
## 🔄 Updates
* **Scoop:**
```powershell
scoop update sanctum
```
* **Winget:**
```powershell
winget upgrade --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
```
---
## 🗑️ Deinstallation
* **Scoop:**
```powershell
scoop uninstall sanctum
```
* **Explorer-Kontextmenü rückstandslos bereinigen:**
```powershell
sanctum.exe unregister
```
+70
View File
@@ -0,0 +1,70 @@
# Rechtliche Hinweise, Compliance & Exportkontrolle (LEGAL)
Dieses Dokument regelt die rechtlichen Rahmenbedingungen, Exportkontroll-Klassifizierungen, Lizenz-Compliance sowie die datenschutzrechtlichen und haftungsrechtlichen Grundlagen der Software **Sanctum**.
---
## 1. Kryptografie-Exportkontrolle & Dual-Use-Klassifizierung
Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrischen Schlüssellängen von 256 Bit (AES-256-GCM), asymmetrischer KDF (Argon2id) sowie BIP-39 Schlüsselableitung.
### A. US Export Administration Regulations (EAR)
* **Klassifizierung (ECCN)**:
* Hardware/Software mit starker Verschlüsselung fällt unter **ECCN 5A002.a** bzw. **5D002** (Commerce Control List, Category 5, Part 2: *Information Security*).
* **Ausnahmetatbestand für frei verfügbaren Quellcode**:
* Gemäß **15 C.F.R. § 742.15(b)** (*Publicly Available Encryption Source Code*) und **15 C.F.R. § 734.3(b)(3)** unterliegt öffentlich zugänglicher Open-Source-Quellcode, der jedermann unentgeltlich und ohne Zugriffsbeschränkungen zur Verfügung steht, **nicht** den Ausfuhrgenehmigungspflichten der EAR.
* Sanctum wird unter der freien MIT-Lizenz auf frei zugänglichen Repositorien (einschließlich Quellcode, Spezifikationen und Build-Skripten) bereitgestellt.
* **Sanktionierte Staaten & Personen (OFAC)**:
* Trotz der allgemeinen Open-Source-Freistellung ist der wissentliche direkte oder indirekte Export/Re-Export an Personen oder Entitäten auf US-Sanktionslisten (OFAC Specially Designated Nationals List - SDN) sowie in Länder unter umfassenden Handelsembargos (Kuba, Iran, Nordkorea, Syrien, besetzte Gebiete der Ukraine) untersagt.
### B. Europäische Union (EU Dual-Use-Verordnung 2021/821)
* **Anhang I, Kategorie 5 Teil 2**:
* Systeme der Informationssicherheit sind grundsätzlich im Anhang I der Dual-Use-Verordnung gelistet.
* **Allgemeine Software-Anmerkung (General Software Note - GSN)**:
* Gemäß der GSN zu Anhang I der Verordnung (EU) 2021/821 unterliegt Software **nicht** der Ausfuhrkontrolle, wenn sie:
1. Allgemein zugänglich ist (*in the public domain*), z. B. durch uneingeschränkten, unentgeltlichen Download; oder
2. Der Mindest-Quellcode für die Installation, den Betrieb oder die Wartung nicht-kontrollierter Güter ist.
* Da Sanctum als freie Open-Source-Software jedermann unentgeltlich zur Verfügung steht, greift die Freistellung der GSN.
---
## 2. Dual-Vault-Architektur (Second Safe) & Herausgabepflichten (Key Disclosure Laws)
Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren Standard-Tresor (Slot 0 / Decoy) wird eine scheinbare Alibi-Datei (Carrier) platziert, die als Träger für den zweiten Tresor (Slot 1 / Second Safe) dient.
> [!WARNING]
> **Wichtiger Sicherheitshinweis & Haftungsausschluss für Anwender**:
> - **Schutzziel**: Die Dual-Vault-Funktion dient dem Schutz vor neugierigen Blicken (Schulterblick), Diebstahl oder beiläufigem Zwang im Alltag, indem ein plausibler Alibi-Tresor mit unkritischen Daten vorgezeigt werden kann.
> - **KEINE forensische "Plausible Deniability"**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält.
> - **Kein Schutz vor behördlicher Beschlagnahme**: Sanctum garantiert **keine** juristisch oder forensisch unnachweisbare Abstreitbarkeit ("Plausible Deniability") und ist **nicht** als RIPA-49-Schutzschild konzipiert.
>
> 1. **Deutschland / Österreich / Schweiz (DACH)**:
> * **Nemo tenetur se ipsum accusare**: Niemand ist verpflichtet, sich in einem Strafverfahren selbst zu belasten (§ 136 Abs. 1 S. 2 StPO, Art. 6 EMRK). Beschuldigte haben das verfassungsrechtliche Recht zu schweigen.
> 2. **Internationale Rechtsordnungen (z. B. UK, USA)**:
> * In Rechtsordnungen mit strafbewehrten Herausgabeanordnungen (z. B. UK RIPA Section 49) schützt Sanctum nicht vor Verurteilungen wegen Missachtung gerichtlicher Anordnungen, falls Ermittler die Existenz verschlüsselter Trägerblöcke feststellen.
---
## 3. Haftungsausschluss & Gewährleistung (EU / DACH Recht)
Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT WARRANTY OF ANY KIND“*) wird für den Geltungsbereich des europäischen und deutschen Rechts wie folgt präzisiert und ergänzt:
### A. Schenkungsrechtliche Haftungsbeschränkung (§ 521 BGB)
1. Sanctum wird dem Anwender unentgeltlich und ohne Gegenleistung überlassen.
2. Gemäß **§ 521 des Bürgerlichen Gesetzbuches (BGB)** haftet der Urheber / Entwickler bei unentgeltlicher Softwareüberlassung **nur für Vorsatz und grobe Fahrlässigkeit**.
3. Die Haftung für einfache/leichte Fahrlässigkeit, mittelbare Schäden, Folgeschäden, Datenverluste, entgangenen Gewinn oder Betriebsunterbrechungen ist im gesetzlich zulässigen Rahmen vollständig ausgeschlossen.
### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
---
## 4. Datenschutz & DSGVO / GDPR (Zero-Telemetry-Garantie)
Sanctum folgt uneingeschränkt dem Prinzip **Privacy by Design and by Default** (Art. 25 DSGVO):
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie.
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
* **Keine Speicherung personenbezogener Daten**: Sanctum speichert lokal keinerlei Protokolle über eingegebene Passwörter, Benutzer-IDs oder Dateinamen außerhalb des verschlüsselten Containers.
* **Windows Host-Hygiene**: Durch das integrierte *Anti-Leak-Shield* und den *RAM-Paging-Schutz* (`VirtualLock`) werden Spuren im Host-System (Thumbs.db, temporäre Dateien, Auslagerungsdatei) aktiv unterbunden.
+114
View File
@@ -0,0 +1,114 @@
# Sanctum 🛡️ — Schnellstartanleitung & Notfallhandbuch
Willkommen bei **Sanctum**! Diese Anleitung führt dich in wenigen Minuten durch die grundlegenden Funktionen zur sicheren Verwaltung deiner verschlüsselten Container unter Windows 10/11 **100% Userland, ohne Administratorrechte und ohne Treiber**.
---
## ⚡ In 3 Minuten startklar
```powershell
# 1. Neuen Tresor mit Notfallkarte anlegen:
sanctum.exe init --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
# 2. Tresor einbinden (wählt automatisch den nächsten freien Buchstaben, z. B. Z:):
sanctum.exe mount --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
# 3. Sicher trennen:
# Entweder Ctrl+C im Konsolenfenster ODER Rechtsklick auf das Schild-Icon im Infobereich (Systray) -> "Aushängen & Beenden".
```
---
## 📂 Die Kernfunktionen im Überblick
### 1. Tresor initialisieren (`init`)
Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
* **Standard-Tresor (Single-Vault)**:
```powershell
sanctum.exe init --path "D:\Tresor\daten.sanctum"
```
* **Dual-Vault Tresor (Zweittresor mit Alibi-Carrier)**:
```powershell
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
```
* Hier legst du **zwei verschiedene Passwörter** fest (mindestens 12 Zeichen):
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
---
### 2. Tresor einbinden (`mount`)
Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- oder Hidden-Safe geöffnet werden soll.
| Befehl | Zweck |
| :--- | :--- |
| `sanctum mount --path <DATEI>` | Standard-Mount. Öffnet das Laufwerk automatisch im Explorer. |
| `sanctum mount --path <DATEI> --drive S` | Bindet den Tresor fest an den Buchstaben `S:` (statt automatischer Wahl). |
| `sanctum mount --path <DATEI> --idle-timeout 300` | Trennt das Laufwerk automatisch nach 5 Minuten (300 Sek.) Inaktivität. |
| `sanctum mount --path <DATEI> --no-open` | Verhindert das automatische Öffnen des Windows Explorers (Schutz vor ShellBag-Spuren). |
| `sanctum mount --path <DATEI> --stealth` | **Lautloser Stealth-Modus**: Keine Terminal-Ausgaben, keine URLs, kein Explorer-Start. |
---
### 3. Notfallrettung & Wartung
* **Integritätsprüfung (FSCK)**:
```powershell
sanctum.exe verify --path "D:\Tresor\daten.sanctum"
```
Überprüft die B-Tree-Struktur der Datenbank und testet sämtliche Chunks gegen ihre kryptografischen AEAD-Authentifizierungs-Tags.
* **Online-Backup im laufenden Betrieb**:
```powershell
sanctum.exe backup --path "D:\Tresor\daten.sanctum" --output "E:\Backup\daten_backup.sanctum"
```
Erzeugt über die SQLite Online Backup API eine konsistente Kopie selbst während Dateien geöffnet sind.
* **Passwort vergessen? Wiederherstellung via BIP-39 Notfallschlüssel**:
```powershell
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
```
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
---
## 🖨️ Druckvorlage: BIP-39 Notfallkarte
Drucke diesen Abschnitt aus oder übertrage die Wörter handschriftlich auf ein Blatt Papier. Bewahre diese Karte physisch getrennt von deinem Computer an einem sicheren Ort (z. B. Tresor, Dokumentenmappe) auf.
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ SANCTUM — KRYPTOGRAFISCHE NOTFALL-WIEDERHERSTELLUNGSKARTE (BIP-39) │
└──────────────────────────────────────────────────────────────────────────────┘
Container: __________________________________________________________________
Erstelldatum: ____.___.202__ Safe: [ ] Standard [ ] Hidden Vault
Hinweis: Alle Wörter sind in Kleinbuchstaben aus der offiziellen BIP-39 Liste.
┌────┬────────────────────────────┬────┬────────────────────────────┐
│ # │ WORT │ # │ WORT │
├────┼────────────────────────────┼────┼────────────────────────────┤
│ 01 │ __________________________ │ 13 │ __________________________ │
│ 02 │ __________________________ │ 14 │ __________________________ │
│ 03 │ __________________________ │ 15 │ __________________________ │
│ 04 │ __________________________ │ 16 │ __________________________ │
│ 05 │ __________________________ │ 17 │ __________________________ │
│ 06 │ __________________________ │ 18 │ __________________________ │
│ 07 │ __________________________ │ 19 │ __________________________ │
│ 08 │ __________________________ │ 20 │ __________________________ │
│ 09 │ __________________________ │ 21 │ __________________________ │
│ 10 │ __________________________ │ 22 │ __________________________ │
│ 11 │ __________________________ │ 23 │ __________________________ │
│ 12 │ __________________________ │ 24 │ __________________________ │
└────┴────────────────────────────┴────┴────────────────────────────┘
⚠️ SICHERHEITSHINWEISE:
1. Wer im Besitz dieser 24 Wörter ist, kann den Tresor ohne Passwort entschlüsseln!
2. Niemals abfotografieren, in Cloud-Notizen speichern oder unverschlüsselt versenden.
3. Bei Verlust beider Passwörter und dieser Karte sind die Daten unwiederbringlich verloren.
```
+117 -13
View File
@@ -4,18 +4,22 @@
# Sanctum 🛡️ # Sanctum 🛡️
**Verschlüsselter Ein-Datei-Container unter Windows 10/11 im reinen Userland** **Verschlüsselter Ein-Datei-Container unter Windows & Linux im reinen Userland**
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 im reinen Userland verwaltet. Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 und Linux im reinen Userland verwaltet.
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan). - **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
- **Windows Explorer-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon. - **Windows Explorer- & Desktop-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon unter Windows sowie `gio mount` / `.desktop`-MIME unter Linux.
- **Native Synchronisation (`sanctum sync`)**: RSync-artiger bidirektionaler Dateiabgleich (`Push` & `Pull`) mit 1-MB-Chunk-Streaming direkt in SQLite. Umgeht das fundamentale 4-GB-Dateilimit des Windows WebClient-Dienstes (`0x800700DF`) für Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+). Inklusive `--dry-run` Simulation, `--delete` Spiegelung und `--checksum` Hashprüfung.
- **In-Place Self-Upgrade (`sanctum upgrade`)**: Aktualisiert das laufende Binary per Knopfdruck direkt über Gitea Releases. Löst Windows File-Locking via `self-replace`, verifiziert jedes Release kryptografisch via **Minisign** (`SHA256SUMS.txt.minisig`) und sperrt Drittanbieter-URLs standardmäßig ab.
- **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback. - **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback.
- **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen. - **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
- **Plausible Deniability (Hidden Safe)**: Dual-Slot-Header. Ein unbenutzter Slot enthält uniformes CSPRNG-Rauschen, das sich nicht von einem echten Hidden Vault unterscheiden lässt. Volle Dateinamen-Verschlüsselung im Hidden Vault. - **Dual-Vault / Second Safe**: 2-Slot-Architektur mit Alibi-Trägerdatei (Modell A). Erlaubt die Trennung von Standard- und vertraulichen Zweittresor-Daten (z. B. Schutz vor Schulterblick im Alltag). Volle Dateinamen-Verschlüsselung im Second Safe.
- **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`). - **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`).
- **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`). - **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`).
- **Statisches Single-Binary**: `sanctum.exe` (~4.6 MB) ohne externe DLL-Abhängigkeiten. - **Statisches Single-Binary**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md).
--- ---
@@ -25,16 +29,16 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet. Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
- **Data Encryption Key (DEK)**: - **Data Encryption Key (DEK)**:
Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt. Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
- **Speichersicherheit (Zeroize)**: - **Speichersicherheit (Zeroize & VirtualLock)**:
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen. Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen. Schlüsseldaten werden mittels `VirtualLock` / `mlock` vor Paging geschützt.
- **Chunk-Verschlüsselung (AES-256-GCM)**: - **Chunk-Verschlüsselung (AES-256-GCM)**:
Dateien werden in Blöcken von 1 MB verschlüsselt. Dateien werden in Blöcken von 1 MB verschlüsselt.
- **Swap-Attack-Schutz**: - **Swap-Attack-Schutz**:
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler. Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
- **Plausible Deniability (Multi-Slot)**: - **Dual-Vault (Multi-Slot & Carrier)**:
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Hidden Vault oder CSPRNG-Dummy. Ein Angreifer kann mathematisch nicht feststellen, ob Slot 1 ungenutzt ist oder einen zweiten Tresor birgt. Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristische "Plausible Deniability" gegen behördliche Beschlagnahme).
- **Dateinamen-Verschlüsselung**: - **Dateinamen-Verschlüsselung**:
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert. Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
- **Kryptografisches Chunk-Shredding**: - **Kryptografisches Chunk-Shredding**:
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben. Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
@@ -76,7 +80,7 @@ Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
# Standard-Container anlegen: # Standard-Container anlegen:
sanctum.exe init --path "C:\Pfad\tresor.sanctum" sanctum.exe init --path "C:\Pfad\tresor.sanctum"
# Container mit Plausible Deniability (Decoy + Hidden Safe) anlegen: # Container mit Zweittresor (Dual-Vault: Decoy + Second Safe) anlegen:
sanctum.exe init --path "C:\Pfad\tresor.sanctum" --with-hidden sanctum.exe init --path "C:\Pfad\tresor.sanctum" --with-hidden
``` ```
*(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).* *(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).*
@@ -94,9 +98,15 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
# Mit Inaktivitäts-Timeout (in Sekunden): # Mit Inaktivitäts-Timeout (in Sekunden):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300 sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
# Lautloser Stealth-Modus (keine Banner/Token-Ausgabe, kein Explorer-Auto-Open):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --stealth
# Explorer-Auto-Open gezielt unterdrücken (maximale ShellBag-OpSec):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
``` ```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault). - **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird automatisch im Explorer geöffnet. - **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen. - **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch. - **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
@@ -161,6 +171,59 @@ sanctum.exe unregister
--- ---
### 8. Native Synchronisation (`sanctum sync`)
Sanctum bietet eine native, rsync-ähnliche Synchronisations-Engine, die Dateien direkt auf Datenbank- und Chunk-Ebene überträgt. Dies löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher beim Kopieren über gemountete WebDAV-Netzlaufwerke konstruktionsbedingt auf maximal 4 GB pro Datei beschränkt ist (`FileSizeLimitInBytes` / Fehler `0x800700DF`).
```powershell
# 1. Vorab-Simulation ausführen (Dry-Run / risikolos prüfen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --dry-run
# 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"
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
# 5. Daten aus dem Tresor wiederherstellen (Pull / Container -> Host):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" /Downloads "D:\Wiederhergestellt\Downloads" --pull
```
- **Dateigrößen**: Dateien beliebiger Größe (**5 GB, 50 GB, 500 GB+**) werden in 1-MB-Streaming-Chunks mit AES-256-GCM direkt übertragen.
- **Fast-Delta Check**: Dateien mit identischer Größe und Modifikationszeitstempel (`mtime`) werden sofort übersprungen.
- **Metadaten-Erhaltung**: Datei-Zeitstempel (`mtime`) werden in beide Richtungen exakt konserviert.
---
### 9. In-Place Self-Upgrade (`sanctum upgrade`)
Aktualisiert die laufende Sanctum-Executable direkt über die offizielle Gitea-Releases-API auf die neueste Version, ohne manuelle Browser-Downloads oder separate Paketmanager:
```powershell
# Nur prüfen, ob ein Update verfügbar ist:
sanctum.exe upgrade --check
# oder mit dem Alias:
sanctum.exe update -c
# Interaktives Upgrade durchführen (zeigt Release-Details und fragt nach Bestätigung):
sanctum.exe upgrade
# Vollautomatisches Upgrade ohne Rückfragen (z. B. in Skripten):
sanctum.exe upgrade -y
# Upgrade erzwingen (Neuinstallation / Reinstall derselben Version):
sanctum.exe upgrade --force
```
- **Plattformunabhängig**: Löst das Windows-Dateilock (`ERROR_ACCESS_DENIED`) via `self-replace` und unterstützt ebenso Linux (statisches musl ELF).
- **Kryptografische Integrität**: Das heruntergeladene Binary wird vor dem Ersetzen zwingend gegen die offizielle `SHA256SUMS.txt` via SHA-256 verifiziert.
- **Paketmanager-Harmonie**: Erkennt automatisch Installationen via Scoop (`scoop update sanctum`) oder Winget (`winget upgrade HaraldPansi.Sanctum`) und warnt den Benutzer.
---
## 🛡️ 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:
@@ -168,6 +231,29 @@ Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`). 1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`).
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts. 2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts.
3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab. 3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab.
4. **RAM-Paging-Schutz (`VirtualLock`)**: Verriegelt sensible Schlüsselstrukturen (`DEK`) im physischen RAM, um das Auslagern in `pagefile.sys` oder `swapfile.sys` durch den Windows Memory Manager zu verhindern.
5. **ShellBag & Forensik-OpSec**: Über die Optionen `--no-open` oder `--stealth` kann das automatische Öffnen des Windows Explorers vollständig unterdrückt werden, um zu verhindern, dass Ordnernamen persistent im ShellBag-Cache (`UsrClass.dat`) protokolliert werden.
6. **VFS Carrier-Schutz**: Die im Decoy-Tresor sichtbare Alibi-Trägerdatei wird im VFS gegen versehentliches Löschen, Überschreiben oder Umbenennen geschützt.
7. **Power-Loss Crash-Consistency**: Atomare Bündelung von Block-Schreiboperationen und Metadaten-Größenaktualisierungen in einer SQLite-Transaktion verhindert Datenkorruption bei plötzlichem Stromausfall.
---
## 🔧 Windows-Kompatibilität & AV/EDR-Tuning
### Windows Defender SmartScreen ("Unbekannter Herausgeber")
Wird die Binärdatei über einen Webbrowser heruntergeladen, versieht Windows sie mit dem *Mark-of-the-Web* (MotW). Da Open-Source-Projekte über kein kostenpflichtiges EV-Code-Signing-Zertifikat verfügen, zeigt Windows beim ersten Start den blauen SmartScreen-Dialog:
- **Einmalige Freigabe im Dialog:** Klick auf **"Weitere Informationen"** $\to$ **"Trotzdem ausführen"**.
- **Schnellfreigabe via PowerShell:**
```powershell
Unblock-File .\sanctum.exe
```
### Windows Defender "Überwachter Ordnerzugriff" (Controlled Folder Access - CFA)
Ist der erweiterte Ransomware-Schutz von Windows aktiv, kann Defender den Schreibzugriff auf Containerdateien in Standard-Benutzerordnern (`Dokumente`, `Desktop`) mit Fehler 5 (*Zugriff verweigert*) blockieren:
- **Abhilfe:** Erlauben Sie `sanctum.exe` in den Windows-Sicherheitseinstellungen (*Viren- & Bedrohungsschutz* $\to$ *Ransomware-Schutz verwalten* $\to$ *App durch überwachten Ordnerzugriff zulassen*), oder speichern Sie Container in einem separaten Verzeichnis (z. B. `C:\Sanctum\`).
### I/O-Performance-Tuning
Windows Defender scannt standardmäßig jeden modifizierten 1-MB-Chunk in Echtzeit. Durch das Hinzufügen der Dateiendung `*.sanctum` zu den Defender-Ausschlüssen lässt sich der I/O-Durchsatz bei großen Kopiervorgängen um 3050% steigern.
--- ---
@@ -180,13 +266,31 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
``` ```
Erzeugt: Erzeugt:
- `dist/sanctum-v0.2.0-windows-x86_64.zip` - `dist/sanctum-v0.7.0-windows-x86_64.zip` (Windows x86_64 ZIP)
- `dist/sanctum-v0.7.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
- `dist/SHA256SUMS.txt` - `dist/SHA256SUMS.txt`
- `dist/SHA256SUMS.txt.minisig` (Kryptografische Minisign-Release-Signatur)
---
## ⚖️ Rechtliche Hinweise, Compliance & Exportkontrolle
- **Kryptografie-Exportkontrolle (EAR & EU Dual-Use)**:
Sanctum nutzt starke Kryptografie (AES-256-GCM, Argon2id). Da der Quellcode als freie Open-Source-Software jedermann unbeschränkt und unentgeltlich zur Verfügung steht, unterliegt er den Ausnahmetatbeständen gem. **15 C.F.R. § 742.15(b)** (US EAR) sowie der **General Software Note (GSN)** der Verordnung (EU) 2021/821.
- **Dual-Vault-Architektur & Herausgabepflichten**:
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) eine praktische Zweittresor-Funktion zum Schutz vor Schulterblick und beiläufiger Nötigung. Dies ist ein technischer Alltags-Schutzmechanismus, kein juristisches Schutzschild und keine forensisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme.
- **Datenschutz & Telemetrie-Freiheit (DSGVO / GDPR)**:
Sanctum arbeitet zu 100% offline und enthält keinerlei Code für Telemetrie, Analytics, Fehlerberichterstattung oder IP-Übertragungen (Art. 25 DSGVO *Privacy by Design*).
- **Haftungsbeschränkung (EU/DACH Recht)**:
Die Software wird als unentgeltliche FOSS-Software bereitgestellt. Gemäß **§ 521 BGB** (Schenkungsrecht) ist die Haftung des Entwicklers auf Vorsatz und grobe Fahrlässigkeit beschränkt. Der Anwender ist für die sichere Verwahrung von Schlüsseln und Backups selbst verantwortlich.
- Ausführliche rechtliche Erläuterungen finden sich in [`LEGAL.md`](LEGAL.md).
--- ---
## 📄 Lizenz & Changelog ## 📄 Lizenz & Changelog
- Lizenziert unter der [MIT License](LICENSE). - Lizenziert unter der [MIT License](LICENSE).
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md). - Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
+382
View File
@@ -0,0 +1,382 @@
# Third-Party Software Licenses & Attributions
Sanctum incorporates open-source software packages statically compiled into the binary.
All included components are distributed under permissive open-source licenses.
This document provides full attribution and license texts in compliance with
Section 4 of the Apache License, Version 2.0, the MIT License, and BSD licenses.
---
## 1. Dependency Inventory
| Crate | Version | License |
| :--- | :--- | :--- |
| `aead` | 0.5.2 | MIT OR Apache-2.0 |
| `aes` | 0.8.4 | MIT OR Apache-2.0 |
| `aes-gcm` | 0.10.3 | Apache-2.0 OR MIT |
| `ahash` | 0.8.12 | MIT OR Apache-2.0 |
| `aho-corasick` | 1.1.5 | Unlicense OR MIT |
| `android_system_properties` | 0.1.6 | MIT OR Apache-2.0 |
| `anstream` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle` | 1.0.14 | MIT OR Apache-2.0 |
| `anstyle-parse` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle-query` | 1.1.5 | MIT OR Apache-2.0 |
| `anstyle-wincon` | 3.0.11 | MIT OR Apache-2.0 |
| `anyhow` | 1.0.104 | MIT OR Apache-2.0 |
| `argon2` | 0.5.3 | MIT OR Apache-2.0 |
| `arrayvec` | 0.7.8 | MIT OR Apache-2.0 |
| `atomic-waker` | 1.1.2 | Apache-2.0 OR MIT |
| `autocfg` | 1.5.1 | Apache-2.0 OR MIT |
| `base64` | 0.22.1 | MIT OR Apache-2.0 |
| `base64ct` | 1.8.3 | Apache-2.0 OR MIT |
| `bip39` | 2.2.2 | CC0-1.0 |
| `bitcoin_hashes` | 0.14.101 | CC0-1.0 |
| `bitflags` | 2.13.1 | MIT OR Apache-2.0 |
| `bitflags` | 1.3.2 | MIT/Apache-2.0 |
| `blake2` | 0.10.6 | MIT OR Apache-2.0 |
| `block` | 0.1.6 | MIT |
| `block-buffer` | 0.10.4 | MIT OR Apache-2.0 |
| `bumpalo` | 3.20.3 | MIT OR Apache-2.0 |
| `bytes` | 1.12.1 | MIT |
| `cc` | 1.4.5 | MIT OR Apache-2.0 |
| `cfg-if` | 1.0.4 | MIT OR Apache-2.0 |
| `chrono` | 0.4.45 | MIT OR Apache-2.0 |
| `cipher` | 0.4.4 | MIT OR Apache-2.0 |
| `clap` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_builder` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_derive` | 4.6.4 | MIT OR Apache-2.0 |
| `clap_lex` | 1.1.0 | MIT OR Apache-2.0 |
| `cocoa` | 0.25.0 | MIT OR Apache-2.0 |
| `cocoa-foundation` | 0.1.2 | MIT OR Apache-2.0 |
| `colorchoice` | 1.0.5 | MIT OR Apache-2.0 |
| `core-foundation` | 0.9.4 | MIT OR Apache-2.0 |
| `core-foundation-sys` | 0.8.7 | MIT OR Apache-2.0 |
| `core-graphics` | 0.23.2 | MIT OR Apache-2.0 |
| `core-graphics-types` | 0.1.3 | MIT OR Apache-2.0 |
| `cpufeatures` | 0.2.17 | MIT OR Apache-2.0 |
| `crypto-common` | 0.1.7 | MIT OR Apache-2.0 |
| `ctr` | 0.9.2 | MIT OR Apache-2.0 |
| `dav-server` | 0.11.0 | Apache-2.0 |
| `derive-where` | 1.6.1 | MIT OR Apache-2.0 |
| `digest` | 0.10.7 | MIT OR Apache-2.0 |
| `displaydoc` | 0.2.7 | MIT OR Apache-2.0 |
| `dyn-clone` | 1.0.20 | MIT OR Apache-2.0 |
| `equivalent` | 1.0.2 | Apache-2.0 OR MIT |
| `errno` | 0.3.14 | MIT OR Apache-2.0 |
| `fallible-iterator` | 0.3.0 | MIT/Apache-2.0 |
| `fallible-streaming-iterator` | 0.1.9 | MIT/Apache-2.0 |
| `find-msvc-tools` | 0.1.12 | MIT OR Apache-2.0 |
| `fnv` | 1.0.7 | Apache-2.0 / MIT |
| `foreign-types` | 0.5.0 | MIT/Apache-2.0 |
| `foreign-types-macros` | 0.2.4 | MIT OR Apache-2.0 |
| `foreign-types-shared` | 0.3.1 | MIT OR Apache-2.0 |
| `form_urlencoded` | 1.2.2 | MIT OR Apache-2.0 |
| `futures-channel` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-core` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-macro` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-sink` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-task` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-util` | 0.3.34 | MIT OR Apache-2.0 |
| `generic-array` | 0.14.7 | MIT |
| `getrandom` | 0.4.3 | MIT OR Apache-2.0 |
| `getrandom` | 0.2.17 | MIT OR Apache-2.0 |
| `ghash` | 0.5.1 | Apache-2.0 OR MIT |
| `h2` | 0.4.19 | MIT |
| `hashbrown` | 0.17.1 | MIT OR Apache-2.0 |
| `hashbrown` | 0.14.5 | MIT OR Apache-2.0 |
| `hashlink` | 0.9.1 | MIT OR Apache-2.0 |
| `headers` | 0.4.1 | MIT |
| `headers-core` | 0.3.0 | MIT |
| `heck` | 0.5.0 | MIT OR Apache-2.0 |
| `hex` | 0.4.3 | MIT OR Apache-2.0 |
| `hex-conservative` | 0.2.3 | CC0-1.0 |
| `htmlescape` | 0.3.1 | Apache-2.0 / MIT / MPL-2.0 |
| `http` | 1.5.0 | MIT OR Apache-2.0 |
| `httparse` | 1.10.1 | MIT OR Apache-2.0 |
| `http-body` | 1.1.0 | MIT |
| `http-body-util` | 0.1.5 | MIT |
| `httpdate` | 1.0.3 | MIT OR Apache-2.0 |
| `hyper` | 1.11.1 | MIT |
| `hyper-util` | 0.1.20 | MIT |
| `iana-time-zone` | 0.1.65 | MIT OR Apache-2.0 |
| `iana-time-zone-haiku` | 0.1.2 | MIT OR Apache-2.0 |
| `icu_collections` | 2.3.0 | Unicode-3.0 |
| `icu_locale_core` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer_data` | 2.3.0 | Unicode-3.0 |
| `icu_properties` | 2.3.0 | Unicode-3.0 |
| `icu_properties_data` | 2.3.0 | Unicode-3.0 |
| `icu_provider` | 2.3.1 | Unicode-3.0 |
| `idna` | 1.1.0 | MIT OR Apache-2.0 |
| `idna_adapter` | 1.2.2 | Apache-2.0 OR MIT |
| `indexmap` | 2.14.2 | Apache-2.0 OR MIT |
| `inout` | 0.1.4 | MIT OR Apache-2.0 |
| `is_terminal_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `itoa` | 1.0.18 | MIT OR Apache-2.0 |
| `js-sys` | 0.3.105 | MIT OR Apache-2.0 |
| `lazy_static` | 1.5.0 | MIT OR Apache-2.0 |
| `libc` | 0.2.189 | MIT OR Apache-2.0 |
| `libsqlite3-sys` | 0.30.1 | MIT |
| `litemap` | 0.8.3 | Unicode-3.0 |
| `lock_api` | 0.4.14 | MIT OR Apache-2.0 |
| `log` | 0.4.34 | MIT OR Apache-2.0 |
| `lz4_flex` | 0.11.6 | MIT |
| `malloc_buf` | 0.0.6 | MIT |
| `matchers` | 0.2.0 | MIT |
| `memchr` | 2.8.3 | Unlicense OR MIT |
| `mime` | 0.3.17 | MIT OR Apache-2.0 |
| `mime_guess` | 2.0.5 | MIT |
| `mio` | 1.2.3 | MIT |
| `nu-ansi-term` | 0.50.3 | MIT |
| `num-traits` | 0.2.19 | MIT OR Apache-2.0 |
| `objc` | 0.2.7 | MIT OR Apache-2.0 |
| `objc_id` | 0.1.1 | MIT |
| `objc-foundation` | 0.1.1 | MIT OR Apache-2.0 |
| `once_cell` | 1.21.4 | MIT OR Apache-2.0 |
| `once_cell_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `opaque-debug` | 0.3.1 | MIT OR Apache-2.0 |
| `padlock` | 0.2.0 | MIT OR Apache-2.0 |
| `parking_lot` | 0.12.5 | MIT OR Apache-2.0 |
| `parking_lot_core` | 0.9.12 | MIT OR Apache-2.0 |
| `password-hash` | 0.5.0 | MIT OR Apache-2.0 |
| `percent-encoding` | 2.3.2 | MIT OR Apache-2.0 |
| `pin-project-lite` | 0.2.17 | Apache-2.0 OR MIT |
| `pkg-config` | 0.3.34 | MIT OR Apache-2.0 |
| `polyval` | 0.6.2 | Apache-2.0 OR MIT |
| `potential_utf` | 0.1.6 | Unicode-3.0 |
| `ppv-lite86` | 0.2.21 | MIT OR Apache-2.0 |
| `proc-macro2` | 1.0.107 | MIT OR Apache-2.0 |
| `quote` | 1.0.47 | MIT OR Apache-2.0 |
| `rand` | 0.8.8 | MIT OR Apache-2.0 |
| `rand_chacha` | 0.3.1 | MIT OR Apache-2.0 |
| `rand_core` | 0.6.4 | MIT OR Apache-2.0 |
| `redox_syscall` | 0.5.18 | MIT OR Apache-2.0 |
| `r-efi` | 6.0.0 | MIT OR Apache-2.0 |
| `regex-automata` | 0.4.18 | MIT OR Apache-2.0 |
| `regex-syntax` | 0.8.11 | MIT OR Apache-2.0 |
| `rpassword` | 7.5.4 | Apache-2.0 |
| `rtoolbox` | 0.0.6 | Apache-2.0 |
| `rusqlite` | 0.32.1 | MIT |
| `rustversion` | 1.0.23 | MIT OR Apache-2.0 |
| `scopeguard` | 1.2.0 | MIT OR Apache-2.0 |
| `serde` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_core` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_derive` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_json` | 1.0.151 | MIT OR Apache-2.0 |
| `sha1` | 0.10.7 | MIT OR Apache-2.0 |
| `sharded-slab` | 0.1.7 | MIT |
| `shlex` | 2.0.1 | MIT OR Apache-2.0 |
| `signal-hook-registry` | 1.4.8 | MIT OR Apache-2.0 |
| `slab` | 0.4.12 | MIT |
| `smallvec` | 1.16.0 | MIT OR Apache-2.0 |
| `socket2` | 0.6.5 | MIT OR Apache-2.0 |
| `stable_deref_trait` | 1.2.1 | MIT OR Apache-2.0 |
| `strsim` | 0.11.1 | MIT |
| `subtle` | 2.6.1 | BSD-3-Clause |
| `syn` | 3.0.5 | MIT OR Apache-2.0 |
| `syn` | 2.0.119 | MIT OR Apache-2.0 |
| `synstructure` | 0.13.2 | MIT |
| `thiserror` | 2.0.20 | MIT OR Apache-2.0 |
| `thiserror-impl` | 2.0.20 | MIT OR Apache-2.0 |
| `thread_local` | 1.1.10 | MIT OR Apache-2.0 |
| `tinystr` | 0.8.4 | Unicode-3.0 |
| `tinyvec` | 1.13.2 | Zlib OR Apache-2.0 OR MIT |
| `tinyvec_macros` | 0.1.1 | MIT OR Apache-2.0 OR Zlib |
| `tokio` | 1.53.1 | MIT |
| `tokio-macros` | 2.7.2 | MIT |
| `tokio-util` | 0.7.19 | MIT |
| `tracing` | 0.1.44 | MIT |
| `tracing-attributes` | 0.1.31 | MIT |
| `tracing-core` | 0.1.36 | MIT |
| `tracing-log` | 0.2.0 | MIT |
| `tracing-subscriber` | 0.3.23 | MIT |
| `tray-item` | 0.10.0 | MIT |
| `twox-hash` | 2.1.4 | MIT |
| `typenum` | 1.20.1 | MIT OR Apache-2.0 |
| `unicase` | 2.9.0 | MIT OR Apache-2.0 |
| `unicode-ident` | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 |
| `unicode-normalization` | 0.1.25 | MIT OR Apache-2.0 |
| `universal-hash` | 0.5.1 | MIT OR Apache-2.0 |
| `url` | 2.5.8 | MIT OR Apache-2.0 |
| `utf8_iter` | 1.0.4 | Apache-2.0 OR MIT |
| `utf8parse` | 0.2.2 | Apache-2.0 OR MIT |
| `uuid` | 1.26.0 | Apache-2.0 OR MIT |
| `valuable` | 0.1.1 | MIT OR Apache-2.0 |
| `vcpkg` | 0.2.15 | MIT/Apache-2.0 |
| `version_check` | 0.9.5 | MIT/Apache-2.0 |
| `wasi` | 0.11.1+wasi-snapshot-preview1 | MIT OR Apache-2.0 |
| `wasm-bindgen` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro-support` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-shared` | 0.2.128 | MIT OR Apache-2.0 |
| `windows_aarch64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_aarch64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows-core` | 0.62.2 | MIT OR Apache-2.0 |
| `windows-implement` | 0.60.2 | MIT OR Apache-2.0 |
| `windows-interface` | 0.59.3 | MIT OR Apache-2.0 |
| `windows-link` | 0.2.1 | MIT OR Apache-2.0 |
| `windows-result` | 0.4.1 | MIT OR Apache-2.0 |
| `windows-strings` | 0.5.1 | MIT OR Apache-2.0 |
| `windows-sys` | 0.61.2 | MIT OR Apache-2.0 |
| `windows-sys` | 0.52.0 | MIT OR Apache-2.0 |
| `windows-targets` | 0.52.6 | MIT OR Apache-2.0 |
| `writeable` | 0.6.4 | Unicode-3.0 |
| `xml` | 1.4.0 | MIT |
| `xml-rs` | 1.0.0 | MIT |
| `xmltree` | 0.12.0 | MIT |
| `yoke` | 0.8.3 | Unicode-3.0 |
| `yoke-derive` | 0.8.2 | Unicode-3.0 |
| `zerocopy` | 0.8.56 | BSD-2-Clause OR Apache-2.0 OR MIT |
| `zerocopy-derive` | 0.8.56 | MIT OR Apache-2.0 |
| `zerofrom` | 0.1.8 | Unicode-3.0 |
| `zerofrom-derive` | 0.1.7 | Unicode-3.0 |
| `zeroize` | 1.9.0 | Apache-2.0 OR MIT |
| `zeroize_derive` | 1.5.0 | Apache-2.0 OR MIT |
| `zerotrie` | 0.2.5 | Unicode-3.0 |
| `zerovec` | 0.11.8 | Unicode-3.0 |
| `zerovec-derive` | 0.11.6 | Unicode-3.0 |
| `zmij` | 1.0.23 | MIT |
---
## 2. License Texts
### The MIT License
```text
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### Apache License, Version 2.0
```text
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file.
```
### 3-Clause BSD License
```text
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
```
### SQLite Copyright & Blessing (Public Domain)
```text
The author disclaims copyright to this source code. In place of a legal notice,
here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
```
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.6.0",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
"bin": "sanctum.exe"
}
},
"checkver": {
"url": "https://gitea.pansi.eu/api/v1/repos/harald/sanctum/releases/latest",
"jsonpath": "$.tag_name",
"regex": "v([\\d.]+)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v$version/sanctum-v$version-windows-x86_64.zip"
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.6.0
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
PublisherSupportUrl: https://gitea.pansi.eu/harald/sanctum/issues
Author: Harald Pansi
License: MIT
LicenseUrl: https://gitea.pansi.eu/harald/sanctum/src/branch/main/LICENSE
ShortDescription: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV
Description: Sanctum ist eine eigenständige, speichersichere CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (.sanctum) unter Windows 10/11 im reinen Userland verwaltet. 100% Userland, keine Adminrechte, keine Treiber.
Moniker: sanctum
Tags:
- encryption
- security
- privacy
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
Installers:
- Architecture: x64
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
ManifestType: singleton
ManifestVersion: 1.6.0
+2
View File
@@ -0,0 +1,2 @@
untrusted comment: minisign public key E735BF824F9856AC
RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU
+138
View File
@@ -0,0 +1,138 @@
param (
[switch]$SkipBuild = $false
)
$ErrorActionPreference = "Stop"
# Repository-Wurzelverzeichnis ermitteln
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
Set-Location $ProjectRoot
# Toolchain-Pfade sicherstellen (Zig + Cargo Bin)
$ZigDir = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\zig.zig_Microsoft.Winget.Source_8wekyb3d8bbwe\zig-x86_64-windows-0.16.0"
$env:PATH = "$ZigDir;C:\Users\pansih\.cargo\bin;C:\Windows\System32;" + $env:PATH
# Version aus Cargo.toml auslesen
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $matches[1]
} else {
Write-Error "Konnte Versionsnummer nicht aus Cargo.toml ermitteln."
exit 1
}
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " Sanctum Linux Release Packaging v$Version (x86_64 musl)" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
# 1. Linux Release-Binary bauen
$Target = "x86_64-unknown-linux-musl"
$BinaryPath = Join-Path $ProjectRoot "target\$Target\release\sanctum"
if (-not $SkipBuild -or -not (Test-Path $BinaryPath)) {
Write-Host "`n[1/3] Kompiliere statisches Linux-Binary via cargo-zigbuild..." -ForegroundColor Yellow
cargo-zigbuild.exe zigbuild --target $Target --release
if ($LASTEXITCODE -ne 0) {
Write-Error "Linux-Build fehlgeschlagen!"
exit $LASTEXITCODE
}
Write-Host "[OK] Linux-Binary erfolgreich gebaut." -ForegroundColor Green
} else {
Write-Host "`n[1/3] Build uebersprungen (-SkipBuild)." -ForegroundColor DarkYellow
}
# 2. Release-Verzeichnisstruktur vorbereiten
Write-Host "`n[2/3] Erstelle Linux-Distributionspaket..." -ForegroundColor Yellow
$DistDir = Join-Path $ProjectRoot "dist"
if (-not (Test-Path $DistDir)) {
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
}
$PackageName = "sanctum-v$Version-linux-x86_64"
$StagingDir = Join-Path $DistDir $PackageName
$TarGzFile = Join-Path $DistDir "$PackageName.tar.gz"
if (Test-Path $StagingDir) {
Remove-Item $StagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
# Dateien kopieren
Copy-Item $BinaryPath -Destination (Join-Path $StagingDir "sanctum") -Force
Copy-Item (Join-Path $ProjectRoot "README.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "LICENSE") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") -Destination $StagingDir -Force
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") -Destination $StagingDir -Force
# .tar.gz Archiv erstellen mit Windows nativem bsdtar
if (Test-Path $TarGzFile) {
Remove-Item $TarGzFile -Force
}
tar.exe -czf $TarGzFile -C $DistDir $PackageName
if ($LASTEXITCODE -ne 0) {
Write-Error "Fehler beim Erstellen des tar.gz-Archivs!"
exit $LASTEXITCODE
}
# Staging-Verzeichnis bereinigen
Remove-Item $StagingDir -Recurse -Force
Write-Host "[OK] Archiv erstellt: $TarGzFile" -ForegroundColor Green
# 3. SHA-256 Pruefsummen berechnen und aktualisieren
Write-Host "`n[3/3] Aktualisiere SHA-256 Pruefsummen..." -ForegroundColor Yellow
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
$TarHash = (Get-FileHash -Path $TarGzFile -Algorithm SHA256).Hash.ToLower()
$ElfHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
# Existierende Pruefsummen lesen und um Linux-Assets ergaenzen/aktualisieren
$ExistingLines = if (Test-Path $ChecksumFile) {
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.tar\.gz" -and $_ -notmatch "\s+sanctum$" }
} else {
@()
}
$AllLines = @()
$AllLines += $ExistingLines
$AllLines += "$TarHash $PackageName.tar.gz"
$AllLines += "$ElfHash sanctum"
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
# Minisign Signatur aktualisieren (S-02)
Write-Host "`nSigniere aktualisierte Pruefsummen mit Minisign..." -ForegroundColor Yellow
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
}
}
}
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
Write-Host "`n============================================================" -ForegroundColor Green
Write-Host " Sanctum Linux Release v$Version erfolgreich gepackt!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Archiv: $TarGzFile ($TarSizeMB MB)"
Write-Host " TAR SHA-256: $TarHash"
Write-Host " ELF SHA-256: $ElfHash"
Write-Host " Checksum-File: $ChecksumFile"
if (Test-Path $SigFile) {
Write-Host " Minisign Sig: $SigFile"
}
Write-Host "`nBereit fuer Gitea Release."
+46 -3
View File
@@ -55,15 +55,22 @@ $PackageName = "sanctum-v$Version-windows-x86_64"
$StagingDir = Join-Path $DistDir $PackageName $StagingDir = Join-Path $DistDir $PackageName
$ZipFile = Join-Path $DistDir "$PackageName.zip" $ZipFile = Join-Path $DistDir "$PackageName.zip"
if (Test-Path $DistDir) { if (-not (Test-Path $DistDir)) {
Remove-Item $DistDir -Recurse -Force New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
}
if (Test-Path $StagingDir) {
Remove-Item $StagingDir -Recurse -Force
} }
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
$ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe" $ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe"
Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe") Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md") Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") (Join-Path $StagingDir "QUICKSTART.md")
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") (Join-Path $StagingDir "INSTALL.md")
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE") Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") (Join-Path $StagingDir "LEGAL.md")
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") (Join-Path $StagingDir "THIRD_PARTY_LICENSES.md")
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md") Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md")
Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse
@@ -80,10 +87,43 @@ $ZipHash = (Get-FileHash -Path $ZipFile -Algorithm SHA256).Hash.ToLower()
$ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower() $ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower()
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt" $ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
@("$ZipHash $PackageName.zip", "$ExeHash sanctum.exe") | Set-Content -Path $ChecksumFile -Encoding utf8 $ExistingLines = if (Test-Path $ChecksumFile) {
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.zip" -and $_ -notmatch "\s+sanctum\.exe$" }
} else {
@()
}
$AllLines = @()
$AllLines += "$ZipHash $PackageName.zip"
$AllLines += "$ExeHash sanctum.exe"
$AllLines += $ExistingLines
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green
# 5. Minisign Signatur generieren (S-02)
Write-Host "`n[5/5] Signiere Pruefsummen mit Minisign..." -ForegroundColor Yellow
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
} else {
Write-Error "Minisign-Signierung fehlgeschlagen!"
exit 1
}
} else {
Write-Warning "minisign.exe nicht gefunden ($MinisignExe). Signatur wurde uebersprungen."
}
} else {
Write-Warning "sanctum-release.key nicht gefunden ($KeyFile). Signatur wurde uebersprungen."
}
# Abschluss-Zusammenfassung # Abschluss-Zusammenfassung
$ZipSize = (Get-Item $ZipFile).Length / 1MB $ZipSize = (Get-Item $ZipFile).Length / 1MB
Write-Host "`n============================================================" -ForegroundColor Green Write-Host "`n============================================================" -ForegroundColor Green
@@ -93,4 +133,7 @@ Write-Host " Archiv: dist\$PackageName.zip ($([math]::Round($ZipSize, 2)
Write-Host " ZIP SHA-256: $ZipHash" Write-Host " ZIP SHA-256: $ZipHash"
Write-Host " EXE SHA-256: $ExeHash" Write-Host " EXE SHA-256: $ExeHash"
Write-Host " Checksum-File: dist\SHA256SUMS.txt" Write-Host " Checksum-File: dist\SHA256SUMS.txt"
if (Test-Path $SigFile) {
Write-Host " Minisign Sig: dist\SHA256SUMS.txt.minisig"
}
Write-Host "`nBereit fuer Gitea Release / Verteilung.`n" Write-Host "`nBereit fuer Gitea Release / Verteilung.`n"
+49 -12
View File
@@ -106,22 +106,59 @@ if (-not $Release) {
$UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets" $UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets"
$AuthHeader = "Authorization: token $Token"
# ZIP Asset hochladen # Hilfsfunktion zum sauberen Hochladen/Ersetzen von Assets
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan function Upload-ReleaseAsset {
$ZipResult = & curl.exe -s -X POST "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip" ` param (
-H "Authorization: token $Token" ` [string]$FilePath,
-F "attachment=@$ZipFile" [string]$AssetName
)
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green if (-not (Test-Path $FilePath)) {
return
}
# SHA256SUMS.txt hochladen Write-Host "Lade $AssetName hoch..." -ForegroundColor Cyan
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
$SumResult = & curl.exe -s -X POST "$UploadUrl?name=SHA256SUMS.txt" `
-H "Authorization: token $Token" `
-F "attachment=@$ChecksumFile"
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green # Vorhandenes Asset mit gleichem Namen ermitteln und ggf. loeschen
$ExistingAssets = try {
Invoke-RestMethod -Uri $UploadUrl -Headers $Headers -Method Get
} catch {
@()
}
$Duplicate = $ExistingAssets | Where-Object { $_.name -eq $AssetName }
if ($Duplicate) {
Write-Host " [i] Ersetze existierendes Asset '$AssetName' (ID: $($Duplicate.id))..." -ForegroundColor DarkYellow
$DeleteUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets/$($Duplicate.id)"
Invoke-RestMethod -Uri $DeleteUrl -Headers $Headers -Method Delete | Out-Null
}
$PathNorm = $FilePath.Replace('\', '/')
$TargetUrl = "${UploadUrl}?name=$AssetName"
$Result = & curl.exe -sS -X POST $TargetUrl -H $AuthHeader -F "attachment=@$PathNorm"
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload von $AssetName fehlgeschlagen: $Result"
exit 1
}
Write-Host "[OK] $AssetName erfolgreich hochgeladen." -ForegroundColor Green
}
# Windows-Assets hochladen
Upload-ReleaseAsset -FilePath $ZipFile -AssetName "sanctum-$TagName-windows-x86_64.zip"
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
Upload-ReleaseAsset -FilePath $ExeFile -AssetName "sanctum.exe"
# Linux-Assets hochladen
$LinuxTar = Join-Path $DistDir "sanctum-$TagName-linux-x86_64.tar.gz"
Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_64.tar.gz"
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
# SHA256SUMS.txt & Minisign-Signatur hochladen
Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
Upload-ReleaseAsset -FilePath $SigFile -AssetName "SHA256SUMS.txt.minisig"
Write-Host "`n============================================================" -ForegroundColor Green Write-Host "`n============================================================" -ForegroundColor Green
+1046
View File
File diff suppressed because it is too large Load Diff
+538 -62
View File
@@ -40,12 +40,74 @@ impl Default for KdfParams {
} }
} }
pub const MIN_MEMORY_COST_KIB: u32 = 19_456;
pub const MAX_MEMORY_COST_KIB: u32 = 262_144;
pub const MIN_TIME_COST: u32 = 2;
pub const MAX_TIME_COST: u32 = 8;
pub const MIN_PARALLELISM: u32 = 1;
pub const MAX_PARALLELISM: u32 = 8;
pub const MIN_PASSWORD_LENGTH: usize = 12;
/// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04).
pub fn validate_kdf_params(params: &KdfParams) -> Result<()> {
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(&params.memory_cost) {
bail!(
"KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)",
params.memory_cost,
MIN_MEMORY_COST_KIB,
MAX_MEMORY_COST_KIB
);
}
if !(MIN_TIME_COST..=MAX_TIME_COST).contains(&params.time_cost) {
bail!(
"KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})",
params.time_cost,
MIN_TIME_COST,
MAX_TIME_COST
);
}
if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(&params.parallelism) {
bail!(
"KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})",
params.parallelism,
MIN_PARALLELISM,
MAX_PARALLELISM
);
}
Ok(())
}
/// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10).
pub fn validate_password(password: &str) -> Result<()> {
if password.chars().count() < MIN_PASSWORD_LENGTH {
bail!(
"Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).",
MIN_PASSWORD_LENGTH,
password.chars().count()
);
}
Ok(())
}
/// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist.
pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
if pass0 == pass1 {
bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!");
} else if pass0.starts_with(pass1) || pass1.starts_with(pass0) {
bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit.");
} else {
Ok(())
}
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab. /// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek( pub fn derive_kek(
password: &str, password: &str,
salt: &[u8], salt: &[u8],
params: &KdfParams, params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> { ) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
let argon2_params = Params::new( let argon2_params = Params::new(
params.memory_cost, params.memory_cost,
params.time_cost, params.time_cost,
@@ -56,10 +118,13 @@ pub fn derive_kek(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]); let mut kek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
argon2 let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
.hash_password_into(password.as_bytes(), salt, &mut *kek) if let Err(e) = res {
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?; let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
}
Ok(kek) Ok(kek)
} }
@@ -67,6 +132,7 @@ pub fn derive_kek(
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK). /// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
pub fn generate_dek() -> Zeroizing<[u8; 32]> { pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]); let mut dek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
OsRng.fill_bytes(&mut *dek); OsRng.fill_bytes(&mut *dek);
dek dek
} }
@@ -78,11 +144,10 @@ pub fn generate_salt() -> [u8; 16] {
salt salt
} }
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM. /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. pub fn wrap_key_payload(
pub fn wrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
dek: &[u8; 32], payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek) let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -91,10 +156,10 @@ pub fn wrap_dek(
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec(); let mut buffer = payload.to_vec();
let tag = cipher let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) .encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?; .map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16]; let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice()); tag_bytes.copy_from_slice(tag.as_slice());
@@ -102,6 +167,36 @@ pub fn wrap_dek(
Ok((buffer, nonce_bytes, tag_bytes)) Ok((buffer, nonce_bytes, tag_bytes))
} }
/// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_key_payload(
kek: &[u8; 32],
wrapped_payload: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Zeroizing<Vec<u8>>> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität. /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek( pub fn unwrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
@@ -109,31 +204,47 @@ pub fn unwrap_dek(
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> { ) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len()); if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
} }
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_dek.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
let mut dek = Zeroizing::new([0u8; 32]); let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer); dek.copy_from_slice(&payload[0..32]);
Ok(dek) Ok(dek)
} }
/// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot0_payload(
kek: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(40);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
pub fn wrap_slot1_payload(
kek: &[u8; 32],
dek_1: &[u8; 32],
dek_0: &[u8; 32],
carrier_node_id: i64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let mut payload = Vec::with_capacity(72);
payload.extend_from_slice(dek_1);
payload.extend_from_slice(dek_0);
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
wrap_key_payload(kek, &payload)
}
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie /// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter KDF/DEK-Slot. Dadurch sind Standard-Container von Containern mit Hidden Vault /// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). /// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) { pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
let mut wrapped_dek = vec![0u8; 32]; let mut wrapped_dek = vec![0u8; 72];
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
let mut tag = [0u8; 16]; let mut tag = [0u8; 16];
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
@@ -144,26 +255,57 @@ pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
(wrapped_dek, nonce, tag, salt) (wrapped_dek, nonce, tag, salt)
} }
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM. /// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault,
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind. /// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern:
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String { /// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian).
#[inline]
pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
let mut aad = [0u8; 16];
aad[..8].copy_from_slice(b"SANCNAME");
aad[8..].copy_from_slice(&parent_id.to_le_bytes());
aad
}
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
pub fn set_allow_legacy_names(allow: bool) {
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
}
pub fn allows_legacy_names() -> bool {
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init"); let cipher = match Aes256Gcm::new_from_slice(dek) {
Ok(c) => c,
Err(_) => return String::new(),
};
let mut buffer = name.as_bytes().to_vec(); let mut buffer = name.as_bytes().to_vec();
let tag = cipher let aad = build_name_aad(parent_id);
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer) let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
.expect("Name encryption"); Ok(t) => t,
format!( Err(_) => return String::new(),
"$h${}${}${}", };
hex::encode(nonce_bytes), let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
hex::encode(tag.as_slice()), combined.extend_from_slice(&nonce_bytes);
hex::encode(&buffer) combined.extend_from_slice(tag.as_slice());
) combined.extend_from_slice(&buffer);
hex::encode(combined)
} }
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> { /// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") { if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect(); let parts: Vec<&str> = rest.split('$').collect();
if parts.len() == 3 { if parts.len() == 3 {
@@ -174,11 +316,13 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
) { ) {
if nonce_bytes.len() == 12 && tag_bytes.len() == 16 { if nonce_bytes.len() == 12 && tag_bytes.len() == 16 {
let cipher = Aes256Gcm::new_from_slice(dek).ok()?; let cipher = Aes256Gcm::new_from_slice(dek).ok()?;
let mut buffer = ct_bytes; // 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct_bytes.clone();
if cipher if cipher
.decrypt_in_place_detached( .decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes), Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME", &aad,
&mut buffer, &mut buffer,
Tag::from_slice(&tag_bytes), Tag::from_slice(&tag_bytes),
) )
@@ -186,6 +330,67 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
{ {
return String::from_utf8(buffer).ok(); return String::from_utf8(buffer).ok();
} }
// 2. Fallback: Statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
}
return None;
}
// Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext)
if stored.len() >= 56 {
if let Ok(bytes) = hex::decode(stored) {
if bytes.len() >= 28 {
let nonce = &bytes[0..12];
let tag = &bytes[12..28];
let ct = &bytes[28..];
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
// 1. Primär: Authentifizierung mit parent_id AAD
let aad = build_name_aad(parent_id);
let mut buffer = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
&aad,
&mut buffer,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Alte statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
} }
} }
} }
@@ -193,6 +398,10 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
None None
} }
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names())
}
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme. /// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> { pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
let mnemonic = bip39::Mnemonic::from_entropy(dek) let mnemonic = bip39::Mnemonic::from_entropy(dek)
@@ -200,16 +409,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
Ok(mnemonic.to_string()) Ok(mnemonic.to_string())
} }
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. pub fn levenshtein_distance(a: &str, b: &str) -> usize {
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme. let a_chars: Vec<char> = a.chars().collect();
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> { let b_chars: Vec<char> = b.chars().collect();
let cleaned = phrase let (m, n) = (a_chars.len(), b_chars.len());
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned) let mut dp = vec![vec![0usize; n + 1]; m + 1];
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?; for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort.
pub fn suggest_bip39_word(word: &str) -> Option<&'static str> {
let word_list = bip39::Language::English.word_list();
let mut best_match = None;
let mut min_dist = usize::MAX;
for &valid in word_list {
let dist = levenshtein_distance(word, valid);
if dist < min_dist {
min_dist = dist;
best_match = Some(valid);
}
}
// Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2)
if min_dist <= 2 {
best_match
} else {
None
}
}
/// Normalisiert eine eingegebene BIP-39 Phrase:
/// - Wandelt in Kleinbuchstaben um (to_lowercase)
/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen
/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing)
pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
let cleaned: String = phrase
.chars()
.map(|c| {
if c.is_alphabetic() {
c.to_ascii_lowercase()
} else {
' '
}
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let words = normalize_mnemonic_phrase(phrase);
if words.is_empty() {
bail!("Die Notfallphrase darf nicht leer sein.");
}
if words.len() != 24 {
bail!(
"Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.",
words.len()
);
}
let word_list = bip39::Language::English.word_list();
let mut invalid_words = Vec::new();
for (idx, word) in words.iter().enumerate() {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
};
invalid_words.push(msg);
}
}
if !invalid_words.is_empty() {
bail!(
"Ungültige Wörter in der Notfallphrase festgestellt:\n • {}",
invalid_words.join("\n")
);
}
let normalized_phrase = words.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase)
.map_err(|_| {
anyhow::anyhow!(
"Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \
Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort."
)
})?;
let entropy = mnemonic.to_entropy(); let entropy = mnemonic.to_entropy();
if entropy.len() != 32 { if entropy.len() != 32 {
@@ -259,8 +573,8 @@ pub fn encrypt_chunk(
vec![COMPRESSION_NONE] vec![COMPRESSION_NONE]
} else { } else {
let compressed = lz4_flex::compress_prepend_size(plaintext); let compressed = lz4_flex::compress_prepend_size(plaintext);
// Nur komprimieren, wenn tatsächlich Bytes gespart werden (+1 Byte für das Flag) // Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag)
if compressed.len() + 1 < plaintext.len() { if compressed.len() + 64 <= plaintext.len() {
let mut buf = Vec::with_capacity(compressed.len() + 1); let mut buf = Vec::with_capacity(compressed.len() + 1);
buf.push(COMPRESSION_LZ4); buf.push(COMPRESSION_LZ4);
buf.extend_from_slice(&compressed); buf.extend_from_slice(&compressed);
@@ -316,7 +630,21 @@ pub fn decrypt_chunk(
match buffer[0] { match buffer[0] {
COMPRESSION_NONE => Ok(buffer[1..].to_vec()), COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
COMPRESSION_LZ4 => { COMPRESSION_LZ4 => {
let decompressed = lz4_flex::decompress_size_prepended(&buffer[1..]) let payload = &buffer[1..];
if payload.len() < 4 {
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
}
let mut size_bytes = [0u8; 4];
size_bytes.copy_from_slice(&payload[0..4]);
let uncompressed_size = u32::from_le_bytes(size_bytes) as usize;
if uncompressed_size > CHUNK_SIZE {
bail!(
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
uncompressed_size,
CHUNK_SIZE
);
}
let decompressed = lz4_flex::decompress_size_prepended(payload)
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?; .map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
Ok(decompressed) Ok(decompressed)
} }
@@ -337,8 +665,8 @@ mod tests {
let password = "SuperSecretMasterPassword123!"; let password = "SuperSecretMasterPassword123!";
let salt = generate_salt(); let salt = generate_salt();
let params = KdfParams { let params = KdfParams {
memory_cost: 1024, // Schneller für Unit-Test memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
@@ -420,6 +748,22 @@ mod tests {
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
} }
#[test]
fn test_lz4_chunk_compression_threshold() {
let dek = generate_dek();
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
let mut random_bytes = vec![0u8; 1000];
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
assert_eq!(decrypted, random_bytes);
}
#[test] #[test]
fn test_v1_backward_compatibility() { fn test_v1_backward_compatibility() {
let dek = generate_dek(); let dek = generate_dek();
@@ -477,27 +821,159 @@ mod tests {
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
} }
#[test]
fn test_bip39_advanced_normalisation_and_suggestions() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).unwrap();
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
// 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization)
let uppercase_phrase = mnemonic_str.to_uppercase();
let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase");
assert_eq!(*dek, *recovered);
// 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern)
let punctuated = format!("{}.", words.join(", "));
let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated");
assert_eq!(*dek, *recovered_punct);
// 3. Typo-Vorschlag (Levenshtein)
let mut typo_words = words.clone();
typo_words[5] = "abondon"; // Typo for abandon
let typo_phrase = typo_words.join(" ");
let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string();
assert!(err.contains("Wort #6 'abondon' ist ungültig"));
assert!(err.contains("Meinten Sie 'abandon'?"));
}
#[test] #[test]
fn test_hidden_node_name_encryption_and_dummy_slot() { fn test_hidden_node_name_encryption_and_dummy_slot() {
let dek = generate_dek(); let dek = generate_dek();
let filename = "ultra_geheimes_dokument.pdf"; let filename = "ultra_geheimes_dokument.pdf";
let encrypted = encrypt_node_name(&dek, filename); let parent_id = 2i64;
assert!(encrypted.starts_with("$h$")); let encrypted = encrypt_node_name(&dek, parent_id, filename);
// Kein verräterisches Präfix mehr! Reines Hex.
assert!(!encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename)); assert!(!encrypted.contains(filename));
assert!(encrypted.len() >= 56);
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name"); let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename); assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap();
let mut static_buf = filename.as_bytes().to_vec();
let static_nonce = [42u8; 12];
let static_tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
.unwrap();
let legacy_static_format = format!(
"$h${}${}${}",
hex::encode(static_nonce),
hex::encode(static_tag),
hex::encode(&static_buf)
);
// Standardmäßig muss Legacy-AAD abgewiesen werden (S-07)
assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none());
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
set_allow_legacy_names(true);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
assert_eq!(decrypted_static, filename);
set_allow_legacy_names(false);
// Mit anderem DEK schlägt Entschlüsselung fehl // Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek(); let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none());
// Dummy-Slot hat korrekte Längen // Dummy-Slot hat korrekte Längen (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
assert_eq!(dummy_dek.len(), 32); assert_eq!(dummy_dek.len(), 72);
assert_eq!(dummy_nonce.len(), 12); assert_eq!(dummy_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.len(), 16); assert_eq!(dummy_salt.len(), 16);
} }
#[test]
fn test_node_name_aad_parent_binding() {
let dek = generate_dek();
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
}
#[test]
fn test_model_a_slot_payloads() {
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
let dek_0 = generate_dek();
let dek_1 = generate_dek();
let carrier_node_id = 42i64;
// Slot 0 Payload: 40 Bytes
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_0.len(), 40);
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
assert_eq!(unwrapped_0.len(), 40);
assert_eq!(&unwrapped_0[0..32], &*dek_0);
let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap());
assert_eq!(recovered_cid_0, carrier_node_id);
// Slot 1 Payload: 72 Bytes
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_1.len(), 72);
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
assert_eq!(unwrapped_1.len(), 72);
assert_eq!(&unwrapped_1[0..32], &*dek_1);
assert_eq!(&unwrapped_1[32..64], &*dek_0);
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
assert_eq!(recovered_cid_1, carrier_node_id);
}
#[test]
fn test_lz4_decompression_bomb_protection() {
use aes_gcm::KeyInit;
let dek = generate_dek();
let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap();
let node_id = 999;
let chunk_index = 0;
let aad = build_chunk_aad(node_id, chunk_index);
// Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE)
let mut malicious_plaintext = Vec::new();
malicious_plaintext.push(COMPRESSION_LZ4);
let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB
malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes());
malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload
let mut nonce_bytes = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut ct = malicious_plaintext.clone();
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
let err_msg = res.err().unwrap().to_string();
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
}
} }
+4
View File
@@ -1,8 +1,12 @@
pub mod carrier;
pub mod crypto; pub mod crypto;
pub mod mount; pub mod mount;
pub mod platform;
pub mod recovery; pub mod recovery;
pub mod storage; pub mod storage;
pub mod sync;
pub mod ui; pub mod ui;
pub mod upgrade;
pub mod verify; pub mod verify;
pub mod vfs; pub mod vfs;
pub mod windows; pub mod windows;
+702 -150
View File
File diff suppressed because it is too large Load Diff
+604 -198
View File
@@ -13,8 +13,9 @@ use hyper_util::rt::TokioIo;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, warn}; use tracing::{debug, warn};
use zeroize::Zeroizing;
use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek}; use crate::crypto::mnemonic_to_dek;
use crate::storage::Database; use crate::storage::Database;
use crate::ui; use crate::ui;
use crate::vfs::SanctumFs; use crate::vfs::SanctumFs;
@@ -22,8 +23,8 @@ use crate::vfs::SanctumFs;
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel. /// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ContainerAuth { pub enum ContainerAuth {
Password(String), Password(Zeroizing<String>),
RecoveryKey(String), RecoveryKey(Zeroizing<String>),
} }
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
@@ -31,56 +32,119 @@ pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase()) format!("{}:", drive_letter.to_ascii_uppercase())
} }
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y`. /// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
pub fn unmount_drive(drive_letter: char) -> Result<()> { pub fn unmount_drive(drive_letter: char) -> Result<()> {
let drive_str = format_drive(drive_letter); #[cfg(windows)]
{
let drive_str = format_drive(drive_letter);
let output = Command::new("net") let output = Command::new("net")
.args(["use", &drive_str, "/delete", "/y"]) .args(["use", &drive_str, "/delete", "/y"])
.output() .output()
.context("Fehler beim Ausführen des Befehls 'net use'")?; .context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
bail!( bail!(
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}", "Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
drive_str, drive_str,
stdout, stdout,
stderr stderr
); );
}
Ok(())
}
#[cfg(unix)]
{
let _ = drive_letter;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = drive_letter;
Ok(())
} }
Ok(())
} }
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/ /persistent:no` ein. /// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein
fn run_net_use_mount(drive_str: &str, port: u16) -> Result<()> { /// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
let url = format!("http://127.0.0.1:{}/", port); fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
#[cfg(windows)]
{
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
let output = Command::new("net") let mut output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"]) .args(["use", drive_str, &url, "/persistent:no"])
.output() .output()
.context("Fehler beim Ausführen des Befehls 'net use'")?; .context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() { // PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr);
bail!( let stdout = String::from_utf8_lossy(&output.stdout);
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}", let is_already_in_use = stderr.contains("85")
drive_str, || stderr.contains("bereits verwendet")
stdout, || stderr.contains("already in use")
stderr || stdout.contains("85")
); || stdout.contains("bereits verwendet")
|| stdout.contains("already in use");
if is_already_in_use {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
let _ = Command::new("net")
.args(["use", drive_str, "/delete", "/y"])
.output();
// Zweiter Versuch nach automatischer Bereinigung
output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim erneuten Ausführen des Befehls 'net use'")?;
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
drive_str,
stdout,
stderr,
webclient_hint
);
}
Ok(())
}
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = drive_str;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = (drive_str, port, session_token);
Ok(())
} }
Ok(())
} }
/// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk ein. /// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk (Windows) bzw. VFS (Linux) ein.
pub async fn mount_container( pub async fn mount_container(
container_path: &Path, container_path: &Path,
drive_letter: char, drive_letter: char,
mount_point: Option<&Path>,
requested_port: Option<u16>, requested_port: Option<u16>,
auth: ContainerAuth, auth: ContainerAuth,
open_explorer: bool, open_explorer: bool,
@@ -88,8 +152,13 @@ pub async fn mount_container(
idle_timeout: Option<u64>, idle_timeout: Option<u64>,
lock_on_screen_lock: bool, lock_on_screen_lock: bool,
anti_leak: bool, anti_leak: bool,
stealth: bool,
) -> Result<()> { ) -> Result<()> {
let drive_str = format_drive(drive_letter); let drive_str = format_drive(drive_letter);
let _ = mount_point;
let _ = open_explorer;
let _ = enable_tray;
let _ = lock_on_screen_lock;
if !container_path.exists() { if !container_path.exists() {
bail!( bail!(
@@ -98,8 +167,10 @@ pub async fn mount_container(
); );
} }
println!(); if !stealth {
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
@@ -107,29 +178,21 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
let (dek, version, vault_id) = match auth { let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => { ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); if !stealth {
let mut unwrapped = None; ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
for slot in &meta.slots {
if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(
&kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
unwrapped = Some((dek, slot.version, slot.slot_id));
break;
}
}
} }
match meta.authenticate(password) {
match unwrapped { Some(keys) => {
Some((dek, ver, slot_id)) => { if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id) }
let vault_id = keys.slot_id();
let ver = keys.version();
let c_dek = keys.carrier_dek();
let c_nid = keys.carrier_node_id();
(keys.0, c_dek, c_nid, ver, vault_id)
} }
None => { None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt"); bail!("Ungültiges Master-Passwort oder Container beschädigt");
@@ -137,28 +200,58 @@ pub async fn mount_container(
} }
} }
ContainerAuth::RecoveryKey(ref phrase) => { ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase) let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?; .context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
let vault_id = if db.has_hidden_vault().unwrap_or(false) { let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
let is_hidden = { let (carrier_dek, final_carrier_node_id, vault_id) = if carrier_node_id > 0 {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); let slot_id = crate::recovery::detect_recovery_key_slot(&db, &dek, meta.version)?;
!children.is_empty() if slot_id == 1 {
}; if !stealth {
if is_hidden { 1 } else { 0 } println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
}
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
} else {
(None, Some(carrier_node_id), 0)
}
} else { } else {
0 (None, None, 0)
}; };
(dek, meta.version, vault_id)
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
} }
}; };
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing) // 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
let token_path_prefix = format!("/{}", session_token);
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
db.clone(),
dek,
carrier_dek,
carrier_node_id,
version,
anti_leak,
vault_id,
);
let last_activity = fs.last_activity(); let last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
.locksystem(FakeLs::new()) .locksystem(FakeLs::new())
.build_handler(); .build_handler();
@@ -180,71 +273,55 @@ pub async fn mount_container(
let bound_addr = listener.local_addr()?; let bound_addr = listener.local_addr()?;
let bound_port = bound_addr.port(); let bound_port = bound_addr.port();
ui::step( if !stealth {
4, #[cfg(windows)]
4, ui::step(
"🌐", 4,
&format!( 4,
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", "🌐",
bound_port, drive_str &format!(
), "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
); bound_port, drive_str
),
);
#[cfg(not(windows))]
ui::step(
4,
4,
"🌐",
&format!(
"Starte WebDAV-Server auf Port {} & initialisiere VFS...",
bound_port
),
);
}
let (shutdown_tx, mut shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Hyper HTTP Server Loop im Hintergrund starten // Hyper HTTP Server Loop im Hintergrund starten
let server_dav = dav_server.clone(); let server_handle = tokio::spawn(serve_webdav_loop(
let server_handle = tokio::spawn(async move { listener,
loop { dav_server,
tokio::select! { token_path_prefix.clone(),
res = listener.accept() => { shutdown_rx,
let (stream, _) = match res { ));
Ok(val) => val,
Err(e) => {
warn!("Verbindungsfehler im TCP-Listener: {e}");
continue;
}
};
let io = TokioIo::new(stream); // Netzlaufwerk bzw. Verzeichnis einbinden
let handler = server_dav.clone(); if let Err(e) = run_mount_command(&drive_str, bound_port, &session_token) {
tokio::spawn(async move {
let service = service_fn(move |req| {
let h = handler.clone();
async move {
Ok::<_, Infallible>(h.handle(req).await)
}
});
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
// Client-Disconnects im Explorer sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
}
_ = shutdown_rx.changed() => {
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
break;
}
}
}
});
// Netzlaufwerk einbinden
if let Err(e) = run_net_use_mount(&drive_str, bound_port) {
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
let _ = server_handle.await; let _ = server_handle.await;
return Err(e); return Err(e);
} }
// Optional automatisch im Windows Explorer öffnen // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
#[cfg(windows)]
if open_explorer { if open_explorer {
let _ = crate::windows::open_in_explorer(drive_letter); let _ = crate::windows::open_in_explorer(drive_letter);
} }
// System-Tray Initialisierung // System-Tray Initialisierung (nur Windows)
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); #[cfg(windows)]
let (_tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)] #[cfg(windows)]
let _tray = if enable_tray { let _tray = if enable_tray {
let icon_source = crate::windows::get_default_system_icon() let icon_source = crate::windows::get_default_system_icon()
@@ -262,7 +339,7 @@ pub async fn mount_container(
let _ = tray.add_menu_item("Im Explorer öffnen", move || { let _ = tray.add_menu_item("Im Explorer öffnen", move || {
let _ = crate::windows::open_in_explorer(dl); let _ = crate::windows::open_in_explorer(dl);
}); });
let s_tx = tray_shutdown_tx.clone(); let s_tx = _tray_shutdown_tx.clone();
let _ = tray.add_menu_item("Trennen & Beenden", move || { let _ = tray.add_menu_item("Trennen & Beenden", move || {
let _ = s_tx.blocking_send(()); let _ = s_tx.blocking_send(());
}); });
@@ -301,8 +378,10 @@ pub async fn mount_container(
} }
} }
// Windows-Sitzungssperre (Win + L Auto-Lock) // Windows-Sitzungssperre (Win + L Auto-Lock, nur Windows)
#[cfg(windows)]
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1); let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _session_monitor = if lock_on_screen_lock { let _session_monitor = if lock_on_screen_lock {
match crate::windows::start_session_lock_monitor(session_lock_tx) { match crate::windows::start_session_lock_monitor(session_lock_tx) {
Ok(guard) => Some(guard), Ok(guard) => Some(guard),
@@ -315,91 +394,418 @@ pub async fn mount_container(
None None
}; };
println!(); if stealth {
println!("┌─────────────────────────────────────────────────────────────┐"); #[cfg(windows)]
println!("│ ✔ Sanctum Container erfolgreich gemountet │"); println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
println!("└─────────────────────────────────────────────────────────────┘"); #[cfg(not(windows))]
println!(); println!("Sanctum: WebDAV-Server auf Port {} bereit.", bound_port);
println!(" • Container: {}", container_path.display()); } else {
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!();
println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); println!("┌─────────────────────────────────────────────────────────────┐");
if vault_id == 1 { println!("│ ✔ Sanctum Container erfolgreich gemountet │");
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)")); println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
#[cfg(windows)]
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
#[cfg(not(windows))]
{
if let Some(mp) = mount_point {
println!(" • Mountpoint: {}", ui::cyan(&mp.display().to_string()));
} else {
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
}
}
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
#[cfg(windows)]
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
#[cfg(windows)]
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
#[cfg(not(windows))]
println!(" • Anti-Leak: VFS-Metadatenfilter aktiv (.directory, .Trash, desktop.ini blockiert)");
}
#[cfg(windows)]
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
#[cfg(windows)]
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
#[cfg(not(windows))]
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
println!();
} }
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
}
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!();
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L) // Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT, nur Windows)
#[cfg(windows)]
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Unix Signale (SIGTERM, SIGHUP)
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
#[cfg(windows)]
tokio::select! { tokio::select! {
res = tokio::signal::ctrl_c() => { res = tokio::signal::ctrl_c() => {
let _ = res; let _ = res;
println!(); if !stealth {
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
} }
_ = tray_shutdown_rx.recv() => { Some(()) = console_close_rx.recv() => {
println!(); if !stealth {
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); println!();
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
}
} }
_ = session_lock_rx.recv() => { Some(()) = tray_shutdown_rx.recv() => {
println!(); if !stealth {
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
}
} }
_ = idle_shutdown_rx.recv() => { Some(()) = session_lock_rx.recv() => {
println!(); if !stealth {
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
} }
} Some(()) = idle_shutdown_rx.recv() => {
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); if !stealth {
let _ = std::io::Write::flush(&mut std::io::stdout()); println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
} }
} }
} }
// SQLite WAL Checkpoint erzwingen #[cfg(unix)]
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); tokio::select! {
let _ = std::io::Write::flush(&mut std::io::stdout()); res = tokio::signal::ctrl_c() => {
if let Err(e) = db.checkpoint() { let _ = res;
println!("{}", ui::red(&format!("Fehler ({e})"))); if !stealth {
} else { println!();
println!("{}", ui::green("OK")); println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
}
_ = async {
match sigterm.as_mut() {
Some(s) => { s.recv().await; }
None => { std::future::pending::<()>().await; }
}
} => {
if !stealth {
println!();
println!(" {} Beendigungssignal (SIGTERM) empfangen.", ui::yellow("[!]"));
}
}
_ = async {
match sighup.as_mut() {
Some(s) => { s.recv().await; }
None => { std::future::pending::<()>().await; }
}
} => {
if !stealth {
println!();
println!(" {} Beendigungssignal (SIGHUP) empfangen.", ui::yellow("[!]"));
}
}
Some(()) = idle_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
} }
println!(); #[cfg(not(any(windows, unix)))]
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("")); tokio::select! {
println!(); res = tokio::signal::ctrl_c() => {
let _ = res;
if !stealth {
println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
}
Some(()) = idle_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
}
if stealth {
let _ = unmount_drive(drive_letter);
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
let _ = db.incremental_vacuum(None);
}
}
let _ = db.checkpoint();
#[cfg(windows)]
println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str);
#[cfg(not(windows))]
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
} else {
#[cfg(windows)]
{
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
println!("{}", ui::yellow(&format!("Warnung ({e})")));
} else {
println!("{}", ui::green("OK"));
}
}
#[cfg(not(windows))]
{
print!(" {} Beende WebDAV-Server ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout());
println!("{}", ui::green("OK"));
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
}
}
}
// SQLite WAL Checkpoint erzwingen
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout());
if let Err(e) = db.checkpoint() {
println!("{}", ui::red(&format!("Fehler ({e})")));
} else {
println!("{}", ui::green("OK"));
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!();
}
drop(db);
// S-10: Verifikation und Bereinigung von temporären SQLite WAL- und SHM-Dateien
let base_os = container_path.as_os_str().to_os_string();
let mut wal = base_os.clone();
wal.push("-wal");
let _ = std::fs::remove_file(wal);
let mut shm = base_os;
shm.push("-shm");
let _ = std::fs::remove_file(shm);
Ok(()) Ok(())
} }
/// Überprüft, ob ein Host-Header exakt auf Loopback (127.0.0.1, localhost, [::1], ::1) zeigt (Anti-DNS-Rebinding & S-06).
/// Striktes Parsing von Host und Port ohne anfällige starts_with-Präfixprüfungen.
pub fn is_loopback_host(host_str: &str) -> bool {
let host_trimmed = host_str.trim().to_ascii_lowercase();
if host_trimmed.is_empty() {
return false;
}
if host_trimmed == "::1" {
return true;
}
let host_part = if host_trimmed.starts_with('[') {
// IPv6 bracketed: [::1] oder [::1]:port
if let Some(bracket_end) = host_trimmed.find(']') {
let inside = &host_trimmed[1..bracket_end];
let after = &host_trimmed[bracket_end + 1..];
if !after.is_empty() {
if !after.starts_with(':') {
return false;
}
if after[1..].parse::<u16>().is_err() {
return false;
}
}
inside
} else {
return false;
}
} else {
// IPv4 oder Hostname: localhost, localhost:8443, 127.0.0.1, 127.0.0.1:8443
if let Some((h, p)) = host_trimmed.rsplit_once(':') {
if p.parse::<u16>().is_ok() {
h
} else {
return false;
}
} else {
&host_trimmed
}
};
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
}
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
token_path_prefix: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
loop {
tokio::select! {
res = listener.accept() => {
let (stream, _) = match res {
Ok(val) => val,
Err(e) => {
warn!("Verbindungsfehler im TCP-Listener: {e}");
continue;
}
};
// RT-01: Schutz gegen Connection-Starvation / Socket-Flooding (CWE-400)
let permit = match conn_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
warn!(
"WebDAV-Verbindungslimit ({} aktive Verbindungen) erreicht: Wehre potenziellen Connection-Starvation-Angriff ab.",
MAX_CONCURRENT_DAV_CONNECTIONS
);
drop(stream);
continue;
}
};
let io = TokioIo::new(stream);
let handler = dav_server.clone();
let expected_prefix = token_path_prefix.clone();
tokio::spawn(async move {
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
let service = service_fn(move |req| {
let h = handler.clone();
let prefix = expected_prefix.clone();
async move {
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
let host_valid = match req.headers().get(hyper::header::HOST) {
Some(host_val) => match host_val.to_str() {
Ok(host_str) => is_loopback_host(host_str),
Err(_) => false,
},
None => false,
};
if !host_valid {
warn!(
"Abgewiesener Zugriff: Fehlender, ungültiger oder externer Host-Header ({:?})",
req.headers().get(hyper::header::HOST)
);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
let path = req.uri().path();
if !path.starts_with(&prefix) {
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
Ok::<_, Infallible>(h.handle(req).await)
}
});
// RT-01: Header-Read-Timeout & TokioTimer zur aktiven Abwehr von Slowloris-Hanging-Sockets
let mut builder = http1::Builder::new();
builder.timer(hyper_util::rt::TokioTimer::new());
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
if let Err(err) = builder.serve_connection(io, service).await {
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
}
_ = shutdown_rx.changed() => {
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_loopback_host_validation() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8443"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:8443"));
// Abweisung externer Hosts oder DNS-Rebinding-Attacken
assert!(!is_loopback_host("evil.com"));
assert!(!is_loopback_host("attacker.local"));
assert!(!is_loopback_host("192.168.1.50"));
assert!(!is_loopback_host("10.0.0.1"));
assert!(!is_loopback_host(""));
}
#[test]
fn test_format_drive() {
assert_eq!(format_drive('s'), "S:");
assert_eq!(format_drive('Z'), "Z:");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Plattform-Abstraktionsschicht für Sanctum.
//!
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
pub use crate::windows::*;
+227 -75
View File
@@ -6,13 +6,26 @@ use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::crypto::{ use crate::crypto::{
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION, decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use crate::storage::{ContainerMeta, Database, SlotMeta}; use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP"; pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1; pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für einen gesicherten Header-Slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotBackup {
pub slot_id: u32,
pub version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
}
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format. /// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup { pub struct HeaderBackup {
@@ -24,6 +37,8 @@ pub struct HeaderBackup {
pub wrapped_dek_hex: String, pub wrapped_dek_hex: String,
pub header_nonce_hex: String, pub header_nonce_hex: String,
pub header_tag_hex: String, pub header_tag_hex: String,
#[serde(default)]
pub slots: Vec<SlotBackup>,
pub created_at: u64, pub created_at: u64,
} }
@@ -34,6 +49,20 @@ impl HeaderBackup {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let slots: Vec<SlotBackup> = meta
.slots
.iter()
.map(|s| SlotBackup {
slot_id: s.slot_id,
version: s.version,
kdf_salt_hex: hex::encode(s.kdf_salt),
kdf_params: s.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
header_nonce_hex: hex::encode(s.header_nonce),
header_tag_hex: hex::encode(s.header_tag),
})
.collect();
Self { Self {
magic: HEADER_BACKUP_MAGIC.to_string(), magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION, backup_version: CURRENT_BACKUP_VERSION,
@@ -43,6 +72,7 @@ impl HeaderBackup {
wrapped_dek_hex: hex::encode(&meta.wrapped_dek), wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce), header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag), header_tag_hex: hex::encode(meta.header_tag),
slots,
created_at: now, created_at: now,
} }
} }
@@ -52,51 +82,97 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix"); bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
} }
let salt_bytes = hex::decode(&self.kdf_salt_hex) let mut slots = Vec::new();
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 { if !self.slots.is_empty() {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len()); for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
slots.push(SlotMeta {
slot_id: s.slot_id,
version: s.version,
kdf_salt,
kdf_params: s.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
});
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
slots.push(slot0);
} }
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
let slot0 = &slots[0];
Ok(ContainerMeta { Ok(ContainerMeta {
version: self.container_format_version, version: slot0.version,
kdf_salt, kdf_salt: slot0.kdf_salt,
kdf_params: self.kdf_params.clone(), kdf_params: slot0.kdf_params.clone(),
wrapped_dek, wrapped_dek: slot0.wrapped_dek.clone(),
header_nonce, header_nonce: slot0.header_nonce,
header_tag, header_tag: slot0.header_tag,
slots: vec![slot0], slots,
}) })
} }
} }
@@ -146,20 +222,44 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
Ok(()) Ok(())
} }
/// Rekonstruiert den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels /// Ermittelt automatisch, ob ein Notfallschlüssel zu Slot 0 (Standard/Decoy) oder Slot 1 (Hidden Vault) gehört (S-05).
/// und initialisiert ein neues Master-Passwort. pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> Result<u32> {
pub fn restore_header_from_recovery_key( if let Ok(Some(carrier_id)) = db.find_carrier_node_id() {
if let Ok(Some(chunk0)) = db.read_chunk(carrier_id, 0) {
let is_dek0 = decrypt_chunk(
dek,
carrier_id,
0,
&chunk0.ciphertext,
&chunk0.nonce,
&chunk0.tag,
version,
)
.is_ok();
if is_dek0 {
return Ok(0);
} else {
return Ok(1);
}
}
}
Ok(0)
}
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
pub fn restore_slot_from_recovery_key(
container_path: &Path, container_path: &Path,
recovery_key: &str, recovery_key: &str,
new_password: &str, new_password: &str,
target_slot_id: u32,
dek_0: Option<&[u8; 32]>,
) -> Result<()> { ) -> Result<()> {
if !container_path.exists() { if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display()); bail!("Containerdatei '{}' existiert nicht.", container_path.display());
} }
if new_password.trim().is_empty() { validate_password(new_password)?;
bail!("Das neue Master-Passwort darf nicht leer sein.");
}
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren // 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key) let dek = mnemonic_to_dek(recovery_key)
@@ -173,34 +273,77 @@ pub fn restore_header_from_recovery_key(
let kek = derive_kek(new_password, &salt, &kdf_params) let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?; .context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
// 3. DEK mit neuem KEK wrappen let db = Database::open(container_path)
let (wrapped_dek, header_nonce, header_tag) = .context("Konnte Container-Datenbank nicht öffnen")?;
wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?;
let slot0 = SlotMeta { let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
slot_id: 0,
version: FORMAT_VERSION, let mut existing_slots = db.read_slots().unwrap_or_default();
kdf_salt: salt,
kdf_params: kdf_params.clone(), let new_slot = if target_slot_id == 1 {
wrapped_dek: wrapped_dek.clone(), // Für Slot 1 wird der echte DEK_0 benötigt (S-05)
header_nonce, let dek_0_bytes = dek_0.ok_or_else(|| {
header_tag, anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
})?;
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped,
header_nonce: nonce,
header_tag: tag,
}
} else {
// Slot 0 (Standard / Decoy Vault) mit 40 Bytes für Modell A
let (wrapped, nonce, tag) = wrap_slot0_payload(&kek, &dek, carrier_node_id)?;
SlotMeta {
slot_id: 0,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped,
header_nonce: nonce,
header_tag: tag,
}
}; };
// Slot ersetzen bzw. einfügen
existing_slots.retain(|s| s.slot_id != target_slot_id);
existing_slots.push(new_slot);
existing_slots.sort_by_key(|s| s.slot_id);
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
if !existing_slots.iter().any(|s| s.slot_id == 1) {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_slot = SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
kdf_salt: dummy_salt,
kdf_params: KdfParams::default(),
wrapped_dek: dummy_dek,
header_nonce: dummy_nonce,
header_tag: dummy_tag,
};
existing_slots.push(dummy_slot);
}
let slot0_meta = existing_slots
.iter()
.find(|s| s.slot_id == 0)
.ok_or_else(|| anyhow::anyhow!("Slot 0 fehlt im Header"))?;
let meta = ContainerMeta { let meta = ContainerMeta {
version: FORMAT_VERSION, version: FORMAT_VERSION,
kdf_salt: salt, kdf_salt: slot0_meta.kdf_salt,
kdf_params, kdf_params: slot0_meta.kdf_params.clone(),
wrapped_dek, wrapped_dek: slot0_meta.wrapped_dek.clone(),
header_nonce, header_nonce: slot0_meta.header_nonce,
header_tag, header_tag: slot0_meta.header_tag,
slots: vec![slot0], slots: existing_slots,
}; };
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta) db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?; .context("Fehler beim Schreiben des rekonstruierten Headers")?;
@@ -209,10 +352,19 @@ pub fn restore_header_from_recovery_key(
Ok(()) Ok(())
} }
/// Rekonstruiert den Decoy-Slot (Slot 0) mithilfe des 24-Wort BIP-39 Notfallschlüssels.
pub fn restore_header_from_recovery_key(
container_path: &Path,
recovery_key: &str,
new_password: &str,
) -> Result<()> {
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::crypto::{generate_dek, generate_salt, unwrap_dek}; use crate::crypto::{generate_dek, generate_salt, unwrap_dek, wrap_dek};
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
@@ -231,8 +383,8 @@ mod tests {
let password = "SuperSecretPassword2026!"; let password = "SuperSecretPassword2026!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).unwrap(); let kek = derive_kek(password, &salt, &kdf_params).unwrap();
@@ -286,8 +438,8 @@ mod tests {
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap(); let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
+751 -63
View File
File diff suppressed because it is too large Load Diff
+992
View File
@@ -0,0 +1,992 @@
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
use crate::ui;
use crate::vfs::is_leak_file;
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
fn calc_local_file_sha256(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
/// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10).
fn calc_vault_node_sha256(
db: &Database,
node: &NodeRecord,
dek: &[u8; 32],
version: u32,
) -> Result<String> {
let mut hasher = Sha256::new();
let total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
for idx in 0..total_chunks {
let record = db
.read_chunk(node.id, idx)?
.ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?;
let decrypted = decrypt_chunk(
dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
version,
)?;
hasher.update(&decrypted);
}
Ok(hex::encode(hasher.finalize()))
}
/// Synchronisationsrichtung
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncDirection {
/// Host-Dateisystem -> Sanctum Container (Upload / Backup)
Push,
/// Sanctum Container -> Host-Dateisystem (Download / Restore)
Pull,
}
/// Optionen für den Synchronisationslauf
#[derive(Debug, Clone)]
pub struct SyncOptions {
pub direction: SyncDirection,
pub delete: bool,
pub dry_run: bool,
pub checksum: bool,
pub exclude_patterns: Vec<String>,
pub quiet: bool,
}
impl Default for SyncOptions {
fn default() -> Self {
Self {
direction: SyncDirection::Push,
delete: false,
dry_run: false,
checksum: false,
exclude_patterns: Vec::new(),
quiet: false,
}
}
}
/// Statistiken über den Synchronisationslauf
#[derive(Debug, Default, Clone)]
pub struct SyncStats {
pub files_scanned: usize,
pub files_transferred: usize,
pub files_skipped: usize,
pub files_deleted: usize,
pub bytes_transferred: u64,
pub elapsed: Duration,
}
/// Ergebnis einer einzelnen Dateiübertragung
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileTransferResult {
Transferred { size: u64 },
Skipped { size: u64 },
DryRunTransferred { size: u64 },
}
/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht.
pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
if is_leak_file(name) {
return true;
}
let norm_rel = rel_path.replace('\\', "/");
let norm_name = name.to_ascii_lowercase();
for pat in patterns {
let p = pat.trim();
if p.is_empty() {
continue;
}
// Wildcard-Muster: *.ext
if p.starts_with("*.") {
let ext = &p[1..].to_ascii_lowercase();
if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) {
return true;
}
}
// Wildcard-Muster: 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;
}
}
// Exakter Name oder Teilpfad
else {
let p_lower = p.to_ascii_lowercase();
if norm_name == p_lower {
return true;
}
if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') {
return true;
}
}
}
false
}
/// Liest bis zu `buf.len()` Bytes aus einer Datei (verlässliche Chunk-Lesung).
fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match file.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(total)
}
/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes.
fn set_local_file_mtime(file: &File, mtime_secs: u64) {
let times = std::fs::FileTimes::new()
.set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
let _ = file.set_times(times);
}
/// Stellt sicher, dass ein Verzeichnispfad im Tresor existiert und gibt den Ziel-Knoten zurück.
pub fn ensure_vault_dir_tree(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
vault_dir_path: &str,
) -> Result<NodeRecord> {
let normalized = vault_dir_path.trim_matches('/');
let root_id = Database::get_root_node_id_for_vault(vault_id);
let root_node = db
.get_node_by_id_in_vault(root_id, vault_id, dek)?
.context("Wurzelknoten im Container nicht gefunden")?;
if normalized.is_empty() {
return Ok(root_node);
}
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
let mut current_id = root_id;
let mut current_node = root_node;
for segment in segments {
let children = db.list_children_in_vault(current_id, vault_id, dek)?;
if let Some(existing) = children.into_iter().find(|c| c.name == segment) {
if !existing.is_dir {
bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment);
}
current_id = existing.id;
current_node = existing;
} else {
let new_dir = db.create_node_in_vault(vault_id, current_id, segment, true, dek)?;
current_id = new_dir.id;
current_node = new_dir;
}
}
Ok(current_node)
}
/// Synchronisiert eine einzelne Host-Datei in den Tresor (Push).
pub fn sync_single_file_to_vault(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
local_path: &Path,
parent_node_id: i64,
file_name: &str,
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
validate_node_name(file_name)?;
let meta = fs::metadata(local_path)
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
let local_size = meta.len();
let local_mtime = meta
.modified()
.unwrap_or(SystemTime::now())
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let children = db.list_children_in_vault(parent_node_id, vault_id, dek)?;
let existing_node = children.into_iter().find(|c| c.name == file_name);
if let Some(ref node) = existing_node {
if node.is_dir {
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
}
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
if !checksum && node.size == local_size && node.modified_at == local_mtime {
return Ok(FileTransferResult::Skipped { size: local_size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && node.size == local_size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: local_size });
}
}
}
}
if dry_run {
return Ok(FileTransferResult::DryRunTransferred { size: local_size });
}
let node_id = match existing_node {
Some(n) => n.id,
None => {
let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
new_node.id
}
};
// 1-MB-Chunk Streaming direkt in SQLite
let mut file = File::open(local_path)
.with_context(|| format!("Konnte '{}' nicht zum Lesen öffnen", local_path.display()))?;
let mut buffer = vec![0u8; CHUNK_SIZE];
let mut chunk_idx = 0u32;
let mut bytes_written = 0u64;
loop {
let n = read_chunk_buffer(&mut file, &mut buffer)?;
if n == 0 {
break;
}
let chunk_data = &buffer[..n];
let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version)?;
bytes_written += n as u64;
db.write_chunk_and_update_size(
node_id,
chunk_idx,
&nonce,
&tag,
&ciphertext,
bytes_written,
local_mtime,
)?;
chunk_idx += 1;
}
// Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
db.update_node_size_and_time(node_id, local_size, local_mtime)?;
Ok(FileTransferResult::Transferred { size: local_size })
}
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull).
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> {
validate_node_name(&node.name)?;
if local_path.exists() {
if let Ok(meta) = fs::metadata(local_path) {
let local_size = meta.len();
let local_mtime = meta
.modified()
.unwrap_or(SystemTime::now())
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if !checksum && local_size == node.size && local_mtime == node.modified_at {
return Ok(FileTransferResult::Skipped { size: node.size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && local_size == node.size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: node.size });
}
}
}
}
}
if dry_run {
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
}
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = File::create(local_path)
.with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?;
let total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
for idx in 0..total_chunks {
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)?;
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(FileTransferResult::Transferred { size: node.size })
}
/// Führt die vollständige Synchronisation zwischen Host und Container aus.
pub fn run_sync(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
source_arg: &str,
target_arg: &str,
options: &SyncOptions,
) -> Result<SyncStats> {
let start_time = Instant::now();
let mut stats = SyncStats::default();
match options.direction {
SyncDirection::Push => {
sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
}
SyncDirection::Pull => {
sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
}
}
stats.elapsed = start_time.elapsed();
Ok(stats)
}
/// Push: Host -> Tresor
fn sync_push(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
source_str: &str,
target_str: &str,
options: &SyncOptions,
stats: &mut SyncStats,
) -> Result<()> {
let local_source = Path::new(source_str);
if !local_source.exists() {
bail!("Lokale Quelle '{}' existiert nicht.", source_str);
}
let target_vault_dir = if target_str.is_empty() { "/" } else { target_str };
if local_source.is_file() {
let file_name = local_source
.file_name()
.context("Ungültiger Dateiname")?
.to_string_lossy();
if is_excluded(&file_name, &file_name, &options.exclude_patterns) {
if !options.quiet {
println!(" {} Ausgeschlossen: {}", ui::dim("[-]"), file_name);
}
return Ok(());
}
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
stats.files_scanned += 1;
match sync_single_file_to_vault(
db,
vault_id,
dek,
version,
local_source,
parent_node.id,
&file_name,
options.checksum,
options.dry_run,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size));
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size));
}
}
FileTransferResult::Skipped { .. } => {
stats.files_skipped += 1;
if !options.quiet {
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), file_name);
}
}
}
} else if local_source.is_dir() {
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
let mut local_relative_paths = HashSet::new();
// Rekursiv alle lokalen Dateien und Ordner erfassen
collect_and_push_dir(
db,
vault_id,
dek,
version,
local_source,
local_source,
parent_node.id,
options,
stats,
&mut local_relative_paths,
)?;
// Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen
if options.delete {
delete_orphans_in_vault(
db,
vault_id,
dek,
parent_node.id,
"",
&local_relative_paths,
options.dry_run,
options.quiet,
stats,
)?;
}
}
Ok(())
}
fn collect_and_push_dir(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
base_dir: &Path,
current_dir: &Path,
current_vault_parent_id: i64,
options: &SyncOptions,
stats: &mut SyncStats,
local_relative_paths: &mut HashSet<String>,
) -> Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
validate_node_name(&file_name)?;
let rel_path = path
.strip_prefix(base_dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if is_excluded(&file_name, &rel_path, &options.exclude_patterns) {
continue;
}
local_relative_paths.insert(rel_path.clone());
if path.is_dir() {
// Ordner im Tresor anlegen falls nötig
let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?;
let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) {
Some(n) => n,
None => {
if !options.dry_run {
db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)?
} else {
// Dummy für dry-run
NodeRecord {
id: -1,
parent_id: Some(current_vault_parent_id),
name: file_name.clone(),
is_dir: true,
size: 0,
created_at: 0,
modified_at: 0,
}
}
}
};
collect_and_push_dir(
db,
vault_id,
dek,
version,
base_dir,
&path,
sub_dir_node.id,
options,
stats,
local_relative_paths,
)?;
} else if path.is_file() {
stats.files_scanned += 1;
match sync_single_file_to_vault(
db,
vault_id,
dek,
version,
&path,
current_vault_parent_id,
&file_name,
options.checksum,
options.dry_run,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size));
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size));
}
}
FileTransferResult::Skipped { .. } => {
stats.files_skipped += 1;
if !options.quiet {
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), rel_path);
}
}
}
}
}
Ok(())
}
pub fn delete_orphans_in_vault(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
current_vault_id: i64,
prefix_rel: &str,
local_relative_paths: &HashSet<String>,
dry_run: bool,
quiet: bool,
stats: &mut SyncStats,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
for child in children {
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
continue;
}
let child_rel = if prefix_rel.is_empty() {
child.name.clone()
} else {
format!("{}/{}", prefix_rel, child.name)
};
if !local_relative_paths.contains(&child_rel) {
stats.files_deleted += 1;
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel);
}
} else {
db.delete_node(child.id)?;
if !quiet {
println!(" {} Gelöscht aus Tresor: {}", ui::red("[-]"), child_rel);
}
}
} else if child.is_dir {
delete_orphans_in_vault(
db,
vault_id,
dek,
child.id,
&child_rel,
local_relative_paths,
dry_run,
quiet,
stats,
)?;
}
}
Ok(())
}
/// Pull: Tresor -> Host
fn sync_pull(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
source_vault_str: &str,
target_host_str: &str,
options: &SyncOptions,
stats: &mut SyncStats,
) -> Result<()> {
let vault_source_node = db
.resolve_path_in_vault(source_vault_str, vault_id, dek)?
.with_context(|| format!("Quelle '{}' im Tresor nicht gefunden", source_vault_str))?;
let local_target_dir = Path::new(target_host_str);
if !vault_source_node.is_dir {
// Einzelne Datei aus Tresor laden
let local_file_path = if local_target_dir.is_dir() {
local_target_dir.join(&vault_source_node.name)
} else {
local_target_dir.to_path_buf()
};
stats.files_scanned += 1;
match sync_single_file_to_host(
db,
vault_id,
dek,
version,
&vault_source_node,
&local_file_path,
options.checksum,
options.dry_run,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size));
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size));
}
}
FileTransferResult::Skipped { .. } => {
stats.files_skipped += 1;
if !options.quiet {
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display());
}
}
}
} else {
// Ganzes Verzeichnis aus Tresor laden
if !options.dry_run {
fs::create_dir_all(local_target_dir)?;
}
let mut vault_relative_paths = HashSet::new();
collect_and_pull_dir(
db,
vault_id,
dek,
version,
vault_source_node.id,
"",
local_target_dir,
options,
stats,
&mut vault_relative_paths,
)?;
// Spiegelung mit --delete: Lokale verwaiste Dateien entfernen
if options.delete && local_target_dir.exists() {
delete_orphans_on_host(
local_target_dir,
local_target_dir,
&vault_relative_paths,
options.dry_run,
options.quiet,
stats,
)?;
}
}
Ok(())
}
fn collect_and_pull_dir(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
version: u32,
vault_node_id: i64,
rel_prefix: &str,
local_target_base: &Path,
options: &SyncOptions,
stats: &mut SyncStats,
vault_relative_paths: &mut HashSet<String>,
) -> Result<()> {
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
for child in children {
validate_node_name(&child.name)?;
let child_rel = if rel_prefix.is_empty() {
child.name.clone()
} else {
format!("{}/{}", rel_prefix, child.name)
};
if is_excluded(&child.name, &child_rel, &options.exclude_patterns) {
continue;
}
// S-08 Path Traversal Guard:
// Prüfe Komponenten des relativen Pfads und stelle sicher, dass der Pfad nicht ausbricht
for comp in Path::new(&child_rel).components() {
match comp {
std::path::Component::Normal(_) => {}
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
}
}
vault_relative_paths.insert(child_rel.clone());
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
if !local_child_path.starts_with(local_target_base) {
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
}
if child.is_dir {
if !options.dry_run {
fs::create_dir_all(&local_child_path)?;
}
collect_and_pull_dir(
db,
vault_id,
dek,
version,
child.id,
&child_rel,
local_target_base,
options,
stats,
vault_relative_paths,
)?;
} else {
stats.files_scanned += 1;
match sync_single_file_to_host(
db,
vault_id,
dek,
version,
&child,
&local_child_path,
options.checksum,
options.dry_run,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size));
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size));
}
}
FileTransferResult::Skipped { .. } => {
stats.files_skipped += 1;
if !options.quiet {
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), child_rel);
}
}
}
}
}
Ok(())
}
fn delete_orphans_on_host(
base_dir: &Path,
current_dir: &Path,
vault_relative_paths: &HashSet<String>,
dry_run: bool,
quiet: bool,
stats: &mut SyncStats,
) -> Result<()> {
if !current_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
let rel_path = path
.strip_prefix(base_dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if !vault_relative_paths.contains(&rel_path) {
stats.files_deleted += 1;
if path.is_dir() {
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path);
}
} else {
fs::remove_dir_all(&path)?;
if !quiet {
println!(" {} Lokalen Ordner gelöscht: {}", ui::red("[-]"), rel_path);
}
}
} else {
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path);
}
} else {
fs::remove_file(&path)?;
if !quiet {
println!(" {} Lokale Datei gelöscht: {}", ui::red("[-]"), rel_path);
}
}
}
} else if path.is_dir() {
delete_orphans_on_host(base_dir, &path, vault_relative_paths, dry_run, quiet, stats)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_excluded_patterns() {
let patterns = vec![
"*.tmp".to_string(),
"*.crdownload".to_string(),
"Thumbs.db".to_string(),
"backup_*".to_string(),
"/logs".to_string(),
];
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
assert!(is_excluded("download.crdownload", "download.crdownload", &patterns));
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
assert!(!is_excluded("important.doc", "sub/important.doc", &patterns));
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
}
}
+21
View File
@@ -100,6 +100,27 @@ pub fn magenta(s: &str) -> String {
} }
} }
/// Formatiert eine Byte-Anzahl lesbar in B, KB, MB, GB oder TB.
pub fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
const TB: f64 = GB * 1024.0;
let b = bytes as f64;
if b >= TB {
format!("{:.2} TB", b / TB)
} else if b >= GB {
format!("{:.2} GB", b / GB)
} else if b >= MB {
format!("{:.2} MB", b / MB)
} else if b >= KB {
format!("{:.2} KB", b / KB)
} else {
format!("{} B", bytes)
}
}
/// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...` /// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...`
pub fn step(num: u8, total: u8, icon: &str, msg: &str) { pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
let tag = cyan(&format!("[{}/{}]", num, total)); let tag = cyan(&format!("[{}/{}]", num, total));
+523
View File
@@ -0,0 +1,523 @@
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{bail, Context, Result};
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::ui;
#[cfg(windows)]
pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
#[cfg(not(windows))]
pub const TARGET_ASSET_NAME: &str = "sanctum";
pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
/// Repräsentiert ein Asset in einem Gitea-Release
#[derive(Debug, Deserialize, Clone)]
pub struct ReleaseAsset {
pub id: u64,
pub name: String,
pub size: u64,
pub browser_download_url: String,
}
/// Repräsentiert ein Release von der Gitea Releases-API
#[derive(Debug, Deserialize, Clone)]
pub struct GiteaRelease {
pub id: u64,
pub tag_name: String,
pub name: String,
pub body: String,
pub html_url: String,
pub published_at: Option<String>,
pub assets: Vec<ReleaseAsset>,
}
/// Optionen für das Upgrade
#[derive(Debug, Clone)]
pub struct UpgradeOptions {
pub check_only: bool,
pub yes: bool,
pub force: bool,
pub base_url: String,
pub insecure_url: bool,
}
impl Default for UpgradeOptions {
fn default() -> Self {
Self {
check_only: false,
yes: false,
force: false,
base_url: OFFICIAL_UPGRADE_URL.to_string(),
insecure_url: false,
}
}
}
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
pub fn verify_minisign_signature(
data: &[u8],
sig_str: &str,
pubkey_str: &str,
) -> Result<()> {
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
let signature = minisign_verify::Signature::decode(sig_str)
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
pubkey
.verify(data, &signature, true)
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
Ok(())
}
/// Erkannter Paketmanager
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
Scoop,
Winget,
}
impl PackageManager {
pub fn name(&self) -> &'static str {
match self {
PackageManager::Scoop => "Scoop",
PackageManager::Winget => "Winget",
}
}
pub fn upgrade_command(&self) -> &'static str {
match self {
PackageManager::Scoop => "scoop update sanctum",
PackageManager::Winget => "winget upgrade HaraldPansi.Sanctum",
}
}
}
/// Prüft, ob die laufende Binary durch einen Paketmanager (Scoop oder Winget) verwaltet wird.
pub fn detect_package_manager() -> Option<PackageManager> {
if let Ok(exe_path) = std::env::current_exe() {
detect_package_manager_from_path(&exe_path)
} else {
None
}
}
/// Hilfsfunktion für die Paketmanager-Erkennung anhand eines Pfads (für Tests isolierbar).
pub fn detect_package_manager_from_path(path: &Path) -> Option<PackageManager> {
let s = path.to_string_lossy().to_lowercase();
if s.contains("scoop\\apps\\sanctum")
|| s.contains("scoop/apps/sanctum")
|| s.contains("scoop\\shims")
|| s.contains("scoop/shims")
{
return Some(PackageManager::Scoop);
}
if s.contains("winget\\packages")
|| s.contains("winget/packages")
|| s.contains("winget\\links")
|| s.contains("winget/links")
{
return Some(PackageManager::Winget);
}
None
}
/// Ruft das neueste Release von der Gitea Releases-API ab.
pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
let base = base_url.trim_end_matches('/');
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
let resp = ureq::get(&api_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set("Accept", "application/json")
.timeout(Duration::from_secs(15))
.call()
.with_context(|| format!("Netzwerkfehler beim Abrufen der Releases von '{}'", api_url))?;
let release: GiteaRelease = resp
.into_json()
.context("Ungültiges JSON-Format von der Gitea Releases-API empfangen")?;
Ok(release)
}
/// Lädt eine Datei von einer URL als Text herunter.
pub fn fetch_text(url: &str) -> Result<String> {
let resp = ureq::get(url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(15))
.call()
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
let text = resp
.into_string()
.with_context(|| format!("Fehler beim Lesen des Texts von '{}'", url))?;
Ok(text)
}
/// Extrahiert die SHA-256 Prüfsumme für ein bestimmtes Asset aus dem Inhalt von `SHA256SUMS.txt`.
pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) -> Option<String> {
for line in sha256sums_content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
let hash = parts[0];
let filename = parts[1].trim_start_matches('*');
if filename.eq_ignore_ascii_case(target_asset) && hash.len() == 64 {
return Some(hash.to_lowercase());
}
}
}
None
}
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
}
/// Vergleicht die aktuelle Version mit der entfernten Version.
/// Gibt `true` zurück, wenn `remote > current`.
pub fn is_newer_version(current: &str, remote: &str) -> bool {
let cur = match parse_clean_version(current) {
Ok(v) => v,
Err(_) => return false,
};
let rem = match parse_clean_version(remote) {
Ok(v) => v,
Err(_) => return false,
};
rem > cur
}
/// Lädt das Ziel-Binary in eine temporäre Datei herunter und validiert dabei den SHA-256 Hash.
pub fn download_and_verify_binary(
download_url: &str,
expected_hash: &str,
expected_size: u64,
) -> Result<PathBuf> {
let resp = ureq::get(download_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(120))
.call()
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
let mut reader = resp.into_reader();
let mut temp_file = tempfile::Builder::new()
.prefix("sanctum_update_")
.suffix(if cfg!(windows) { ".exe" } else { "" })
.tempfile()
.context("Konnte temporäre Update-Datei nicht sicher erstellen")?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
let mut downloaded_bytes = 0u64;
println!(" • Herunterladen von: {}", download_url);
print!(" • Fortschritt: ");
let _ = std::io::stdout().flush();
loop {
let n = reader
.read(&mut buffer)
.context("Fehler beim Lesen des Download-Datenstroms")?;
if n == 0 {
break;
}
temp_file
.as_file_mut()
.write_all(&buffer[..n])
.context("Fehler beim Schreiben in die temporäre Datei")?;
hasher.update(&buffer[..n]);
downloaded_bytes += n as u64;
if expected_size > 0 {
let percent = (downloaded_bytes as f64 / expected_size as f64 * 100.0).min(100.0);
print!(
"\r • Fortschritt: {} / {} ({:.1}%)",
ui::format_bytes(downloaded_bytes),
ui::format_bytes(expected_size),
percent
);
} else {
print!(
"\r • Fortschritt: {}",
ui::format_bytes(downloaded_bytes)
);
}
let _ = std::io::stdout().flush();
}
println!();
temp_file
.as_file_mut()
.flush()
.context("Fehler beim Flush der temporären Datei")?;
// SHA-256 Integritätsprüfung
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
let expected_lower = expected_hash.to_lowercase();
if calculated_hash != expected_lower {
// temp_file wird beim Drop automatisch gelöscht
bail!(
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
expected_lower,
calculated_hash
);
}
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
let (_, path) = temp_file
.keep()
.context("Konnte temporäre Datei nicht für Ersetzung persistieren")?;
Ok(path)
}
/// Führt den In-Place-Austausch des laufenden Binaries durch.
pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
let current_exe = std::env::current_exe()
.context("Konnte den Pfad der aktuell ausgeführten Datei nicht ermitteln")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(temp_binary) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(temp_binary, perms);
}
}
let replace_res = self_replace::self_replace(temp_binary);
let _ = fs::remove_file(temp_binary);
replace_res.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
Ok(current_exe)
}
/// Führt den gesamten Upgrade-Workflow aus.
pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version)));
println!(" • Server: {}", options.base_url);
println!();
// S-02: Prüfung auf abweichende Server-URL
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
if !is_official_server {
if !options.insecure_url {
bail!(
"Sicherheitsverstoß: Abweichende Server-URL '{}' ist nicht vertrauenswürdig.\n\
Aus Sicherheitsgründen sind Drittanbieter-URLs standardmäßig gesperrt.\n\
Falls beabsichtigt, verwenden Sie das Flag '--insecure-url'.",
options.base_url
);
}
// Auch mit --insecure-url ist zwingend eine manuelle Bestätigung erforderlich (nicht mit -y überschreibbar!)
println!(
" {} WARNUNG: Sie aktualisieren von einem inoffiziellen Server: {}",
ui::red("[SICHERHEITSWARNUNG]"),
options.base_url
);
println!(" Offizieller Server ist: {}", OFFICIAL_UPGRADE_URL);
print!(" Möchten Sie dieses ungesicherte Update WIRKLICH fortsetzen? [Tippen Sie 'JA' zur Bestätigung]: ");
let _ = std::io::stdout().flush();
let mut confirm = String::new();
std::io::stdin()
.read_line(&mut confirm)
.context("Fehler beim Einlesen der Bestätigung")?;
if confirm.trim() != "JA" {
bail!("Upgrade von Drittanbieter-URL durch Benutzer abgebrochen.");
}
println!();
}
// 1. Paketmanager-Prüfung
if let Some(pm) = detect_package_manager() {
println!(
" {} Sanctum wird anscheinend über {} verwaltet!",
ui::yellow("[!]"),
pm.name()
);
println!(
" Empfohlener Aktualisierungsbefehl: {}",
ui::cyan(pm.upgrade_command())
);
println!();
if !options.force && !options.yes && !options.check_only {
print!(" Möchten Sie das direkte In-Place-Upgrade trotzdem fortsetzen? [j/N]: ");
let _ = std::io::stdout().flush();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("Fehler beim Einlesen der Benutzereingabe")?;
let trimmed = answer.trim().to_lowercase();
if trimmed != "j" && trimmed != "ja" && trimmed != "y" && trimmed != "yes" {
println!(" Upgrade abgebrochen.");
return Ok(());
}
println!();
}
}
// 2. Neuestes Release abfragen
println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]"));
let release = fetch_latest_release(&options.base_url)?;
let remote_tag = &release.tag_name;
let is_newer = is_newer_version(current_version, remote_tag);
println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) });
println!(" • Release-Name: {}", release.name);
if let Some(ref pub_at) = release.published_at {
println!(" • Veröffentlicht: {}", pub_at);
}
println!(" • Release-URL: {}", release.html_url);
println!();
if !is_newer && !options.force {
println!(
" {} Sie verwenden bereits die neueste Version von Sanctum.",
ui::green("")
);
return Ok(());
}
if options.check_only {
if is_newer {
println!(
" {} Ein Update auf {} ist verfügbar! Führen Sie 'sanctum upgrade' aus.",
ui::yellow("[!]"),
remote_tag
);
}
return Ok(());
}
// 3. Ziel-Asset, Checksumme und Minisign-Signatur identifizieren
let target_asset = release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(TARGET_ASSET_NAME))
.ok_or_else(|| {
anyhow::anyhow!(
"Im Release '{}' wurde kein passendes Binary für Ihr Betriebssystem ('{}') gefunden!",
remote_tag,
TARGET_ASSET_NAME
)
})?;
let checksum_asset = release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_ASSET_NAME))
.ok_or_else(|| {
anyhow::anyhow!(
"Im Release '{}' wurde keine Prüfsummendatei ('{}') gefunden!",
remote_tag,
CHECKSUM_ASSET_NAME
)
})?;
let sig_asset = release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_SIG_ASSET_NAME))
.ok_or_else(|| {
anyhow::anyhow!(
"Im Release '{}' wurde keine Minisign-Signatur ('{}') gefunden! Unsignierte Updates werden verweigert.",
remote_tag,
CHECKSUM_SIG_ASSET_NAME
)
})?;
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
anyhow::anyhow!(
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
TARGET_ASSET_NAME
)
})?;
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
if !options.yes {
println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size));
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
println!();
print!(
" Möchten Sie Sanctum jetzt von v{} auf {} aktualisieren? [J/n]: ",
current_version, remote_tag
);
let _ = std::io::stdout().flush();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("Fehler beim Einlesen der Benutzereingabe")?;
let trimmed = answer.trim().to_lowercase();
if trimmed == "n" || trimmed == "nein" || trimmed == "no" {
println!(" Upgrade abgebrochen.");
return Ok(());
}
println!();
}
// 6. Download & Verifikation
println!(" {} Lade neues Binary herunter ...", ui::dim("[-]"));
let temp_file = download_and_verify_binary(
&target_asset.browser_download_url,
&expected_hash,
target_asset.size,
)?;
// 7. In-Place-Austausch
println!(" {} Führe In-Place-Austausch durch ...", ui::dim("[-]"));
let updated_exe = apply_upgrade(&temp_file)?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Pfad: {}", updated_exe.display());
println!(" • Neue Version: {}", ui::green(remote_tag));
println!();
Ok(())
}
+67 -9
View File
@@ -110,7 +110,7 @@ pub fn verify_container(
node_map.insert(node.id, node.clone()); node_map.insert(node.id, node.clone());
} }
// Root-Knoten prüfen (id = 1) // Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault)
match node_map.get(&1) { match node_map.get(&1) {
Some(root) => { Some(root) => {
if !root.is_dir { if !root.is_dir {
@@ -125,9 +125,18 @@ pub fn verify_container(
} }
} }
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen // Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes { for node in &all_nodes {
if node.id == 1 { if node.id == 1 || node.id == 2 {
continue; continue;
} }
@@ -174,6 +183,54 @@ pub fn verify_container(
} }
} }
// Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1)
let mut vault0_nodes = HashSet::new();
let mut vault1_nodes = HashSet::new();
vault0_nodes.insert(1i64);
if node_map.contains_key(&2) {
vault1_nodes.insert(2i64);
}
let mut changed = true;
while changed {
changed = false;
for node in &all_nodes {
if let Some(pid) = node.parent_id {
if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) {
vault0_nodes.insert(node.id);
changed = true;
} else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) {
vault1_nodes.insert(node.id);
changed = true;
}
}
}
}
// Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört
let active_vault_nodes = if let Some(active_dek) = dek {
let is_vault1 = {
let mut found_v1 = false;
for node in &all_nodes {
if node.parent_id == Some(2) {
if crate::crypto::decrypt_node_name(active_dek, 2, &node.name).is_some() {
found_v1 = true;
break;
}
}
}
found_v1
};
if is_vault1 {
Some(&vault1_nodes)
} else {
Some(&vault0_nodes)
}
} else {
None
};
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers() let chunk_headers = db.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?; .context("Fehler beim Abrufen der Chunk-Liste")?;
@@ -187,8 +244,9 @@ pub fn verify_container(
)); ));
} }
if let Some(active_dek) = dek { if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
if full_chunks { // Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault)
if target_nodes.contains(&node_id) && full_chunks {
match db.read_chunk(node_id, chunk_index) { match db.read_chunk(node_id, chunk_index) {
Ok(Some(record)) => { Ok(Some(record)) => {
match decrypt_chunk( match decrypt_chunk(
@@ -252,8 +310,8 @@ mod tests {
let password = "HealthyTestPassword123!"; let password = "HealthyTestPassword123!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).unwrap(); let kek = derive_kek(password, &salt, &kdf_params).unwrap();
@@ -283,7 +341,7 @@ mod tests {
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1); assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 2); // Root + photos assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_chunks, 2); assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0); assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0); assert_eq!(report.orphan_nodes, 0);
@@ -304,8 +362,8 @@ mod tests {
let password = "BitrotTestPassword123!"; let password = "BitrotTestPassword123!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).unwrap(); let kek = derive_kek(password, &salt, &kdf_params).unwrap();
+336 -46
View File
@@ -14,8 +14,9 @@ use dav_server::{
}; };
use futures_util::stream; use futures_util::stream;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use zeroize::Zeroizing; use zeroize::{Zeroize, Zeroizing};
use crate::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord}; use crate::storage::{Database, NodeRecord};
@@ -134,6 +135,7 @@ impl SanctumFile {
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32, format_version: u32,
last_activity: Arc<AtomicU64>, last_activity: Arc<AtomicU64>,
append: bool,
) -> Self { ) -> Self {
let meta = SanctumMetaData { let meta = SanctumMetaData {
is_dir: node.is_dir, is_dir: node.is_dir,
@@ -142,10 +144,12 @@ impl SanctumFile {
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
}; };
let cursor = if append { node.size } else { 0 };
Self { Self {
node_id: node.id, node_id: node.id,
file_size: node.size, file_size: node.size,
cursor: 0, cursor,
db, db,
dek, dek,
meta, meta,
@@ -164,8 +168,15 @@ impl SanctumFile {
} }
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
fn flush_cached_chunk(&mut self) -> Result<(), FsError> { /// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
fn flush_cached_chunk_and_size(&mut self) -> Result<(), FsError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some((idx, ref data, true)) = self.cached_chunk { if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) = let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
@@ -173,16 +184,35 @@ impl SanctumFile {
FsError::GeneralFailure FsError::GeneralFailure
})?; })?;
self.db if let Err(e) = self.db.write_chunk_and_update_size(
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext) self.node_id,
.map_err(|e| { idx,
error!("DB-Fehler beim Schreiben des Chunks: {e}"); &nonce,
FsError::GeneralFailure &tag,
})?; &ciphertext,
self.file_size,
now,
) {
error!("DB-Fehler beim atomaren Chunk- und Size-Write #{idx}: {e}");
// CHAOS-03: Bei I/O- oder Disk-Full-Fehlern den Cache sauber invalidieren,
// um Folgefehler und Panic-/Warnungsschleifen beim Drop zu unterbinden!
self.cached_chunk = None;
return Err(FsError::GeneralFailure);
}
if let Some((_, _, ref mut dirty)) = self.cached_chunk { if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = false; *dirty = false;
} }
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} else if self.meta.size != self.file_size {
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
return Err(FsError::GeneralFailure);
}
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} }
Ok(()) Ok(())
} }
@@ -195,7 +225,10 @@ impl SanctumFile {
}; };
if !is_current { if !is_current {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize();
}
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| { let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}"); error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
@@ -229,14 +262,12 @@ impl SanctumFile {
impl Drop for SanctumFile { impl Drop for SanctumFile {
fn drop(&mut self) { fn drop(&mut self) {
if let Err(e) = self.flush_cached_chunk() { if let Err(e) = self.flush_cached_chunk_and_size() {
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e); warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
} }
let now = SystemTime::now() if let Some((_, ref mut data, _)) = self.cached_chunk {
.duration_since(UNIX_EPOCH) data.zeroize();
.map(|d| d.as_secs()) }
.unwrap_or(0);
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
} }
} }
@@ -324,7 +355,7 @@ impl DavFile for SanctumFile {
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen // Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) { if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
} }
src = &src[to_write..]; src = &src[to_write..];
@@ -360,19 +391,7 @@ impl DavFile for SanctumFile {
fn flush(&mut self) -> FsFuture<'_, ()> { fn flush(&mut self) -> FsFuture<'_, ()> {
self.touch(); self.touch();
Box::pin(async move { Box::pin(async move {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.db
.update_node_size_and_time(self.node_id, self.file_size, now)
.map_err(|e| {
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
FsError::GeneralFailure
})?;
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
Ok(()) Ok(())
}) })
} }
@@ -386,6 +405,10 @@ impl DavFile for SanctumFile {
pub struct SanctumFs { pub struct SanctumFs {
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
#[allow(dead_code)]
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
carrier_node_id: Option<i64>,
carrier_fs: Option<CarrierFs>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
@@ -412,14 +435,71 @@ impl SanctumFs {
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
) -> Self {
Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id)
}
pub fn with_carrier(
db: Database,
dek: Zeroizing<[u8; 32]>,
carrier_dek: Option<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self { ) -> Self {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new);
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
let carrier_node_id = carrier_node_id.or_else(|| {
if vault_id == 0 {
db.find_carrier_node_id().ok().flatten()
} else {
None
}
});
let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
match CarrierFs::load(
db.clone(),
c_nid,
c_dek.clone(),
dek_arc.clone(),
format_version,
anti_leak,
) {
Ok(cfs) => Some(cfs),
Err(e) => {
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
None
}
}
} else {
None
}
} else {
None
};
// Forensischer RAM-Paging-Schutz via VirtualLock (verhindert Auslagerung in pagefile.sys)
crate::windows::lock_memory(dek_arc.as_ptr(), 32);
if let Some(ref c_dek) = carrier_dek_arc {
crate::windows::lock_memory(c_dek.as_ptr(), 32);
}
Self { Self {
db, db,
dek: Arc::new(dek), dek: dek_arc,
carrier_dek: carrier_dek_arc,
carrier_node_id,
carrier_fs,
format_version, format_version,
anti_leak, anti_leak,
vault_id, vault_id,
@@ -432,7 +512,11 @@ impl SanctumFs {
} }
pub fn last_activity(&self) -> Arc<AtomicU64> { pub fn last_activity(&self) -> Arc<AtomicU64> {
self.last_activity.clone() if let Some(ref cfs) = self.carrier_fs {
cfs.last_activity()
} else {
self.last_activity.clone()
}
} }
pub fn is_anti_leak_enabled(&self) -> bool { pub fn is_anti_leak_enabled(&self) -> bool {
@@ -440,11 +524,15 @@ impl SanctumFs {
} }
pub fn touch(&self) { pub fn touch(&self) {
let now = SystemTime::now() if let Some(ref cfs) = self.carrier_fs {
.duration_since(UNIX_EPOCH) cfs.touch();
.map(|d| d.as_secs()) } else {
.unwrap_or(0); let now = SystemTime::now()
self.last_activity.store(now, Ordering::Relaxed); .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_activity.store(now, Ordering::Relaxed);
}
} }
fn path_to_str(path: &DavPath) -> String { fn path_to_str(path: &DavPath) -> String {
@@ -460,6 +548,7 @@ impl SanctumFs {
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> { fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
validate_path_safety(path)?;
self.db self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek) .resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
@@ -472,6 +561,7 @@ impl SanctumFs {
} }
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> { fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
validate_path_safety(name)?;
self.db self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| { .map_err(|e| {
@@ -481,18 +571,45 @@ impl SanctumFs {
} }
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
validate_path_safety(new_name)?;
self.db self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
} }
} }
/// Validiert, dass ein Pfad oder Dateiname keine Null-Bytes oder unzulässige Steuerzeichen enthält (CHAOS-02).
pub fn validate_path_safety(path: &str) -> Result<(), FsError> {
if path.contains('\0') {
return Err(FsError::Forbidden);
}
for c in path.chars() {
if (c as u32) < 0x20 && c != '\t' {
return Err(FsError::Forbidden);
}
}
Ok(())
}
impl Drop for SanctumFs {
fn drop(&mut self) {
crate::windows::unlock_memory(self.dek.as_ptr(), 32);
if let Some(ref c_dek) = self.carrier_dek {
crate::windows::unlock_memory(c_dek.as_ptr(), 32);
}
}
}
impl DavFileSystem for SanctumFs { impl DavFileSystem for SanctumFs {
fn open<'a>( fn open<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
options: OpenOptions, options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> { ) -> FsFuture<'a, Box<dyn DavFile>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.open(path, options);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let (parent_path, file_name) = self.split_parent_and_name(&path_str); let (parent_path, file_name) = self.split_parent_and_name(&path_str);
@@ -520,6 +637,11 @@ impl DavFileSystem for SanctumFs {
let node = match existing_node { let node = match existing_node {
Some(n) => { Some(n) => {
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
return Err(FsError::Forbidden);
}
if n.is_dir && (options.write || options.append) { if n.is_dir && (options.write || options.append) {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -571,6 +693,7 @@ impl DavFileSystem for SanctumFs {
self.dek.clone(), self.dek.clone(),
self.format_version, self.format_version,
self.last_activity.clone(), self.last_activity.clone(),
options.append,
); );
Ok(Box::new(file) as Box<dyn DavFile>) Ok(Box::new(file) as Box<dyn DavFile>)
}) })
@@ -579,10 +702,13 @@ impl DavFileSystem for SanctumFs {
fn read_dir<'a>( fn read_dir<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
_meta: ReadDirMeta, meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.read_dir(path, meta);
}
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
@@ -615,11 +741,12 @@ impl DavFileSystem for SanctumFs {
} }
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.metadata(path);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
let node = self let node = self
.resolve_path(&path_str)? .resolve_path(&path_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -636,10 +763,17 @@ impl DavFileSystem for SanctumFs {
} }
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.symlink_metadata(path);
}
self.metadata(path) self.metadata(path)
} }
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.create_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -668,6 +802,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_dir(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -684,6 +822,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
if let Some(carrier_id) = self.carrier_node_id {
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
return Err(FsError::Forbidden);
}
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -693,6 +838,10 @@ impl DavFileSystem for SanctumFs {
} }
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.remove_file(path);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -704,6 +853,11 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Löschen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -717,6 +871,10 @@ impl DavFileSystem for SanctumFs {
from: &'a DavPath, from: &'a DavPath,
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
@@ -726,6 +884,11 @@ impl DavFileSystem for SanctumFs {
.resolve_path(&from_str)? .resolve_path(&from_str)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) { if self.anti_leak && is_leak_file(to_name) {
@@ -745,6 +908,10 @@ impl DavFileSystem for SanctumFs {
if dest.is_dir { if dest.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten!
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
self.db self.db
.delete_node(dest.id) .delete_node(dest.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -780,6 +947,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
if let Some(dest) = self.resolve_path(&to_str)? {
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
}
let to_parent = self let to_parent = self
.resolve_path(to_parent_path)? .resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
@@ -878,11 +1052,11 @@ mod tests {
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek("testpwd", &salt, &kdf_params).unwrap(); let kek = derive_kek("TestMasterPassword123!", &salt, &kdf_params).unwrap();
let dek = generate_dek(); let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
@@ -1003,4 +1177,120 @@ mod tests {
let new_time = act_arc.load(Ordering::Relaxed); let new_time = act_arc.load(Ordering::Relaxed);
assert!(new_time > 1000); assert!(new_time > 1000);
} }
#[tokio::test]
async fn test_carrier_protection_in_decoy_vault() {
let (fs, _dir) = create_test_fs(true);
// Erstelle eine Carrier-Datei "system_backup.dat"
let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap();
let carrier_id = carrier_node.id;
// Erstelle FS mit bekanntem carrier_node_id
let protected_fs = SanctumFs::with_carrier(
fs.db.clone(),
(*fs.dek).clone(),
None,
Some(carrier_id),
FORMAT_VERSION,
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// 1. Lesen muss erlaubt sein
let mut read_opts = OpenOptions::default();
read_opts.read = true;
assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok());
// 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden)
let mut write_opts = OpenOptions::default();
write_opts.write = true;
assert!(matches!(
protected_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
));
let mut trunc_opts = OpenOptions::default();
trunc_opts.truncate = true;
assert!(matches!(
protected_fs.open(&carrier_path, trunc_opts).await,
Err(FsError::Forbidden)
));
// 3. Löschen der Carrier-Datei muss verboten sein
assert!(matches!(
protected_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
));
// 4. Umbenennen der Carrier-Datei muss verboten sein
let rename_target = DavPath::new("/renamed_backup.dat").unwrap();
assert!(matches!(
protected_fs.rename(&carrier_path, &rename_target).await,
Err(FsError::Forbidden)
));
// 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein
let other_path = DavPath::new("/other.txt").unwrap();
let mut other_opts = OpenOptions::default();
other_opts.write = true;
other_opts.create_new = true;
protected_fs.open(&other_path, other_opts).await.unwrap();
assert!(matches!(
protected_fs.rename(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
// 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein
assert!(matches!(
protected_fs.copy(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
}
#[tokio::test]
async fn test_path_safety_rejects_null_bytes_and_control_chars() {
let (fs, _dir) = create_test_fs(true);
// 1. Null-Byte im Pfad
let null_path = DavPath::new("/bad\0file.txt");
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
assert!(validate_path_safety("/bad\0file.txt").is_err());
// 2. Steuerzeichen < 0x20
assert!(validate_path_safety("/bad\x01file.txt").is_err());
assert!(validate_path_safety("/bad\rfile.txt").is_err());
assert!(validate_path_safety("/bad\nfile.txt").is_err());
// 3. Gültiger Pfad
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
assert!(validate_path_safety("/path/to/subfolder/file.pdf").is_ok());
}
#[tokio::test]
async fn test_write_atomic_and_cache_invalidation() {
let (fs, _dir) = create_test_fs(true);
let path = DavPath::new("/atomic_test.bin").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut file = fs.open(&path, opts).await.unwrap();
// 1. Schreibe 500 Bytes
let payload = Bytes::from(vec![42u8; 500]);
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
file.flush().await.unwrap();
let node = fs.resolve_path("/atomic_test.bin").unwrap().unwrap();
assert_eq!(node.size, 500);
// Chunk in DB prüfen
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
assert!(!chunk.ciphertext.is_empty());
}
} }
+372 -93
View File
@@ -1,5 +1,6 @@
use std::process::Command; use std::process::Command;
#[allow(unused_imports)]
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D'). /// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
@@ -32,14 +33,68 @@ pub fn find_next_available_drive() -> Result<char> {
} }
} }
/// Öffnet das eingebundene Netzlaufwerk direkt im Windows Explorer. /// Öffnet das eingebundene Netzlaufwerk oder Verzeichnis direkt im systemeigenen Dateimanager
/// (Windows: Explorer, macOS: open, Linux: xdg-open).
pub fn open_in_explorer(drive_char: char) -> Result<()> { pub fn open_in_explorer(drive_char: char) -> Result<()> {
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase()); #[cfg(windows)]
Command::new("explorer.exe") {
.arg(&drive_path) let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
.spawn() Command::new("explorer.exe")
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?; .arg(&drive_path)
Ok(()) .spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
let _ = drive_char;
let _ = Command::new("open").arg(".").spawn();
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = drive_char;
let _ = Command::new("xdg-open").arg(".").spawn();
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = drive_char;
Ok(())
}
}
/// Öffnet einen beliebigen Pfad im nativen Dateimanager der Plattform.
pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
#[cfg(windows)]
{
Command::new("explorer.exe")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Command::new("xdg-open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = path;
Ok(())
}
} }
/// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED). /// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED).
@@ -67,104 +122,146 @@ pub fn notify_shell_associations_changed() {
); );
} }
} }
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = Command::new("update-desktop-database").spawn();
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
}
} }
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer (HKCU, 100% Userland, keine Adminrechte). /// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
pub fn register_explorer_integration() -> Result<()> { pub fn register_explorer_integration() -> Result<()> {
let current_exe = std::env::current_exe() #[cfg(windows)]
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?; {
let exe_str = current_exe.display().to_string(); let current_exe = std::env::current_exe()
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
let exe_str = current_exe.display().to_string();
let reg_commands = [ let reg_commands = [
// 1. .sanctum Erweiterung mit ProgID verknüpfen // 1. .sanctum Erweiterung mit ProgID verknüpfen
( (
r"HKCU\Software\Classes\.sanctum", r"HKCU\Software\Classes\.sanctum",
"", "",
"Sanctum.Container", "Sanctum.Container",
), ),
// 2. ProgID Metadaten & Beschreibung // 2. ProgID Metadaten & Beschreibung
( (
r"HKCU\Software\Classes\Sanctum.Container", r"HKCU\Software\Classes\Sanctum.Container",
"", "",
"Sanctum Verschlüsselter Container", "Sanctum Verschlüsselter Container",
), ),
// 3. Icon // 3. Icon
( (
r"HKCU\Software\Classes\Sanctum.Container\DefaultIcon", r"HKCU\Software\Classes\Sanctum.Container\DefaultIcon",
"", "",
&format!("\"{exe_str}\",0"), &format!("\"{exe_str}\",0"),
), ),
// 4. Standard-Doppelklick-Aktion: Mount // 4. Standard-Doppelklick-Aktion: Mount
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\open", r"HKCU\Software\Classes\Sanctum.Container\shell\open",
"", "",
"Als Laufwerk einbinden", "In Sanctum öffnen",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\open\command", r"HKCU\Software\Classes\Sanctum.Container\shell\open\command",
"", "",
&format!("\"{exe_str}\" mount --path \"%1\""), &format!("\"{exe_str}\" mount \"%1\""),
), ),
// 5. Kontextmenü-Aktion: Integritätsprüfung // 5. Kontextmenü-Aktion: Verify / FSCK
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\verify", r"HKCU\Software\Classes\Sanctum.Container\shell\verify",
"", "",
"Integrität prüfen (FSCK)", "Integrität prüfen (FSCK)",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command", r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command",
"", "",
&format!("\"{exe_str}\" verify --path \"%1\""), &format!("cmd /k \"\"{exe_str}\" verify \"%1\"\""),
), ),
// 6. Kontextmenü-Aktion: Header sichern // 6. Kontextmenü-Aktion: Header-Backup
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header", r"HKCU\Software\Classes\Sanctum.Container\shell\backup",
"", "",
"Header sichern", "Header sichern (Disaster Recovery)",
), ),
( (
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header\command", r"HKCU\Software\Classes\Sanctum.Container\shell\backup\command",
"", "",
&format!("\"{exe_str}\" backup-header --path \"%1\""), &format!("cmd /k \"\"{exe_str}\" backup-header \"%1\"\""),
), ),
]; ];
for (key, val_name, val_data) in reg_commands { for (key, val_name, val_data) in reg_commands {
let mut cmd = Command::new("reg"); let mut cmd = Command::new("reg");
cmd.arg("add").arg(key); cmd.arg("add").arg(key);
if val_name.is_empty() { if val_name.is_empty() {
cmd.arg("/ve"); cmd.arg("/ve");
} else { } else {
cmd.arg("/v").arg(val_name); cmd.arg("/v").arg(val_name);
}
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
}
} }
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?; notify_shell_associations_changed();
if !output.status.success() { Ok(())
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
}
} }
#[cfg(not(windows))]
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let apps_dir = home_path.join(".local/share/applications");
let mime_dir = home_path.join(".local/share/mime/packages");
let _ = std::fs::create_dir_all(&apps_dir);
let _ = std::fs::create_dir_all(&mime_dir);
notify_shell_associations_changed(); let desktop_content = "[Desktop Entry]\nType=Application\nName=Sanctum\nComment=Verschlüsselter Ein-Datei-Container\nExec=sanctum mount %f\nIcon=security-high\nTerminal=true\nMimeType=application/x-sanctum;\nCategories=Utility;Security;\n";
Ok(()) let _ = std::fs::write(apps_dir.join("sanctum.desktop"), desktop_content);
let mime_content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n <mime-type type=\"application/x-sanctum\">\n <comment>Sanctum Verschlüsselter Container</comment>\n <glob pattern=\"*.sanctum\"/>\n </mime-type>\n</mime-info>\n";
let _ = std::fs::write(mime_dir.join("application-x-sanctum.xml"), mime_content);
notify_shell_associations_changed();
}
Ok(())
}
} }
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU). /// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU) bzw. unter Linux aus Freedesktop.
pub fn unregister_explorer_integration() -> Result<()> { pub fn unregister_explorer_integration() -> Result<()> {
let keys_to_delete = [ #[cfg(windows)]
r"HKCU\Software\Classes\.sanctum", {
r"HKCU\Software\Classes\Sanctum.Container", let keys_to_delete = [
]; r"HKCU\Software\Classes\.sanctum",
r"HKCU\Software\Classes\Sanctum.Container",
];
for key in keys_to_delete { for key in keys_to_delete {
let _ = Command::new("reg") let _ = Command::new("reg")
.args(["delete", key, "/f"]) .args(["delete", key, "/f"])
.output(); .output();
}
notify_shell_associations_changed();
Ok(())
}
#[cfg(not(windows))]
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
notify_shell_associations_changed();
}
Ok(())
} }
notify_shell_associations_changed();
Ok(())
} }
/// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray. /// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray.
@@ -198,10 +295,9 @@ pub fn get_default_system_icon() -> Option<tray_item::IconSource> {
/// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre. /// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre.
/// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet. /// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet.
#[cfg(windows)]
pub struct SessionLockGuard { pub struct SessionLockGuard {
#[cfg(windows)]
hwnd: isize, hwnd: isize,
#[cfg(windows)]
join_handle: Option<std::thread::JoinHandle<()>>, join_handle: Option<std::thread::JoinHandle<()>>,
} }
@@ -419,10 +515,184 @@ pub fn start_session_lock_monitor(
Ok(SessionLockGuard) Ok(SessionLockGuard)
} }
// ─────────────────────────────────────────────────────────────
// Console Close Event Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
// ─────────────────────────────────────────────────────────────
#[cfg(windows)]
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
#[cfg(windows)]
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
const CTRL_C_EVENT: u32 = 0;
const CTRL_BREAK_EVENT: u32 = 1;
const CTRL_CLOSE_EVENT: u32 = 2;
const CTRL_LOGOFF_EVENT: u32 = 5;
const CTRL_SHUTDOWN_EVENT: u32 = 6;
match ctrl_type {
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
if let Some(dl) = *guard {
let drive_str = format!("{}:", dl.to_ascii_uppercase());
let _ = std::process::Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output();
}
}
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
if let Ok(guard) = CONSOLE_CTRL_TX.lock() {
if let Some(ref tx) = *guard {
let _ = tx.blocking_send(());
}
}
1 // TRUE: Event abgearbeitet
}
CTRL_C_EVENT | CTRL_BREAK_EVENT => {
// Ctrl+C wird primär von tokio::signal::ctrl_c() behandelt
0
}
_ => 0,
}
}
/// RAII Guard zur sauberen Deregistrierung des Win32 Console-Control-Handlers.
pub struct ConsoleCtrlGuard;
impl Drop for ConsoleCtrlGuard {
fn drop(&mut self) {
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe {
SetConsoleCtrlHandler(Some(console_ctrl_routine), 0);
}
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
*guard = None;
}
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
*guard = None;
}
}
}
}
/// Registriert einen Win32 Console Control Handler für CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT
/// und CTRL_SHUTDOWN_EVENT, um verwaiste Netzlaufwerke beim Schließen des Konsolenfensters zu verhindern.
pub fn start_console_ctrl_monitor(
shutdown_tx: tokio::sync::mpsc::Sender<()>,
drive_letter: char,
) -> Result<ConsoleCtrlGuard> {
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
*guard = Some(shutdown_tx);
}
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
*guard = Some(drive_letter);
}
let res = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_routine), 1) };
if res == 0 {
bail!("Konnte Win32 SetConsoleCtrlHandler nicht registrieren");
}
Ok(ConsoleCtrlGuard)
}
#[cfg(not(windows))]
{
let _ = shutdown_tx;
let _ = drive_letter;
Ok(ConsoleCtrlGuard)
}
}
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys unter Windows bzw. Swap unter Linux/macOS).
pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualLock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualLock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(unix)]
{
extern "C" {
fn mlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { mlock(ptr as *const std::ffi::c_void, len) == 0 }
}
#[cfg(not(any(windows, unix)))]
{
let _ = (ptr, len);
false
}
}
/// Entriegelt einen zuvor mit `lock_memory` geschützten Speicherbereich im RAM.
pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualUnlock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualUnlock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(unix)]
{
extern "C" {
fn munlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { munlock(ptr as *const std::ffi::c_void, len) == 0 }
}
#[cfg(not(any(windows, unix)))]
{
let _ = (ptr, len);
false
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_virtual_lock_memory_lifecycle() {
let buffer = vec![0x42u8; 4096];
let _ = lock_memory(buffer.as_ptr(), buffer.len());
let _ = unlock_memory(buffer.as_ptr(), buffer.len());
assert_eq!(buffer[0], 0x42);
}
#[test] #[test]
fn test_find_next_available_drive() { fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive"); let drive = find_next_available_drive().expect("Find next drive");
@@ -438,4 +708,13 @@ mod tests {
// Dropping monitor closes message loop and joins thread cleanly // Dropping monitor closes message loop and joins thread cleanly
drop(monitor); drop(monitor);
} }
#[test]
fn test_console_ctrl_monitor_lifecycle() {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let monitor = start_console_ctrl_monitor(tx, 'Z');
assert!(monitor.is_ok());
drop(monitor);
}
} }
+472
View File
@@ -0,0 +1,472 @@
use std::path::PathBuf;
use bytes::Bytes;
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use sanctum::vfs::SanctumFs;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id));
path
}
#[tokio::test]
async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let path = temp_db_path("carrier_accounting");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB)
let carrier_name = "system_backup.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
let created_cid = db
.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
assert_eq!(created_cid, Some(carrier_node_id));
// 1. Authentifizierung beider Passwörter
let meta = db.read_meta().unwrap();
let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.slot_id(), 0);
assert_eq!(**auth_decoy.dek(), *dek_0);
assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id));
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.slot_id(), 1);
assert_eq!(**auth_hidden.dek(), *dek_1);
assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0);
assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id));
// 2. Decoy Mount: Schutz der Alibi-Datei (system_backup.dat)
let decoy_fs = SanctumFs::with_carrier(
db.clone(),
auth_decoy.dek().clone(),
auth_decoy.carrier_dek(),
auth_decoy.carrier_node_id(),
auth_decoy.version(),
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// Metadaten der Alibi-Datei im Decoy prüfen
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
assert_eq!(carrier_meta.len(), carrier_size_bytes);
assert!(!carrier_meta.is_dir());
// Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden
let write_opts = OpenOptions {
write: true,
..Default::default()
};
assert!(
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
assert!(
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht gelöscht werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
let new_name = DavPath::new("/renamed.iso").unwrap();
assert!(
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
"Alibi-Datei darf nicht umbenannt werden"
);
// Alibi-Datei KANN im Decoy-Mount gelesen werden
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
assert_eq!(first_mb.len(), CHUNK_SIZE);
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer)
let root_path = DavPath::new("/").unwrap();
let mut stream = hidden_fs
.read_dir(&root_path, ReadDirMeta::None)
.await
.expect("Read dir root");
use futures_util::StreamExt;
let mut entries = Vec::new();
while let Some(item) = stream.next().await {
entries.push(item.unwrap().name());
}
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
// Ordner erstellen
let secret_dir = DavPath::new("/Classified").unwrap();
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
// Datei im Ordner anlegen und schreiben
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
let create_opts = OpenOptions {
create: true,
write: true,
..Default::default()
};
let mut secret_file = hidden_fs
.open(&secret_file_path, create_opts)
.await
.expect("Create secret file");
let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault";
secret_file
.write_bytes(Bytes::from_static(secret_content))
.await
.expect("Write secret content");
secret_file.flush().await.expect("Flush secret file");
drop(secret_file);
// Datei lesen und verifizieren
let read_opts = OpenOptions {
read: true,
..Default::default()
};
let mut read_handle = hidden_fs
.open(&secret_file_path, read_opts)
.await
.expect("Open secret file for read");
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
assert_eq!(&read_data[..], secret_content);
drop(read_handle);
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
let mut big_file = hidden_fs
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Create big file");
let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes
let mut payload = vec![0u8; payload_size];
OsRng.fill_bytes(&mut payload);
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
big_file.flush().await.expect("Flush big file");
drop(big_file);
// Datei zurücklesen und Bit-für-Bit verifizieren
let mut read_big = hidden_fs
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open big file");
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
assert_eq!(read_big_bytes.len(), payload_size);
assert_eq!(&read_big_bytes[..], &payload[..]);
drop(read_big);
// Datei umbenennen
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
// Datei löschen (Blöcke werden geshreddert und freigegeben)
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
// Checkpoint SQLite
db.checkpoint().unwrap();
// 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG
// Ein Angreifer besitzt nur das Decoy-Passwort (dek_0).
// Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch.
// ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln!
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
assert!(
report.is_healthy(),
"Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}",
report.errors
);
assert_eq!(
report.corrupted_chunks, 0,
"Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!"
);
assert_eq!(
report.orphan_nodes, 0,
"Es darf keine verwaisten Knoten geben!"
);
// Aufräumen
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_model_a_container_file_size_invariance() {
let path = temp_db_path("carrier_size_invariance");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: 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((
"virtual_disk.vhdx",
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
// Initiale Dateigröße messen
let initial_file_size = std::fs::metadata(&path).unwrap().len();
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
let mut handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file");
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
OsRng.fill_bytes(&mut random_data);
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
handle.flush().await.expect("Flush 4MB");
drop(handle);
db.checkpoint().unwrap();
// Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen
let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len();
// Die Dateigröße auf der Festplatte DARF NICHT WACHSEN!
// Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben.
assert_eq!(
initial_file_size, size_after_hidden_writes,
"Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}",
initial_file_size, size_after_hidden_writes
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_carrier_file_drop_and_append_mode() {
let path = temp_db_path("carrier_drop_append");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let carrier_name = "test_carrier.iso";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "HiddenSecretPassword2026!";
let kdf_params = KdfParams {
memory_cost: 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,
);
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
let mut write_handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.await
.expect("Open file for write");
let initial_data = b"Hello from unflushed write!";
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
// WICHTIG: KEIN write_handle.flush()! Nur drop:
drop(write_handle);
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
let mut read_handle = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read");
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
drop(read_handle);
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
let append_data = b" - Appended data at EOF!";
let mut append_handle = hidden_fs
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
.await
.expect("Open file for append");
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
drop(append_handle); // Drop sichert auch hier
// Prüfe den vollständigen Dateiinhalt nach Append
let mut read_handle_2 = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.await
.expect("Open file for read after append");
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
let mut expected = Vec::new();
expected.extend_from_slice(initial_data);
expected.extend_from_slice(append_data);
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
drop(read_handle_2);
let _ = std::fs::remove_file(&path);
}
+386 -14
View File
@@ -11,6 +11,7 @@ use sanctum::{
crypto::{ crypto::{
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
}, },
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key}, recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
storage::Database, storage::Database,
@@ -34,8 +35,8 @@ async fn test_sanctum_full_container_lifecycle() {
// 1. Initialisierung // 1. Initialisierung
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, // Schnell für Tests memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation"); let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
@@ -176,8 +177,8 @@ async fn test_sanctum_password_change() {
// 1. Initialisierung mit Passwort v1 // 1. Initialisierung mit Passwort v1
let salt_v1 = generate_salt(); let salt_v1 = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1"); let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
@@ -262,8 +263,8 @@ async fn test_sanctum_lz4_compression_efficiency() {
let password = "CompressTestPassword2026!"; let password = "CompressTestPassword2026!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK"); let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
@@ -333,8 +334,8 @@ async fn test_sanctum_v1_backward_compatibility() {
let password = "V1LegacyPassword!"; let password = "V1LegacyPassword!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK"); let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
@@ -392,8 +393,8 @@ async fn test_sanctum_disaster_recovery_workflow() {
let initial_password = "PrimaryPassword2026!"; let initial_password = "PrimaryPassword2026!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK"); let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
@@ -511,8 +512,8 @@ async fn test_anti_leak_and_inactivity_shield() {
let password = "ShieldedContainerPass2026!"; let password = "ShieldedContainerPass2026!";
let salt = generate_salt(); let salt = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek"); let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
@@ -625,8 +626,8 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
let salt0 = generate_salt(); let salt0 = generate_salt();
let salt1 = generate_salt(); let salt1 = generate_salt();
let kdf_params = KdfParams { let kdf_params = KdfParams {
memory_cost: 1024, memory_cost: MIN_MEMORY_COST_KIB,
time_cost: 1, time_cost: MIN_TIME_COST,
parallelism: 1, parallelism: 1,
}; };
@@ -792,6 +793,377 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
let _ = std::fs::remove_file(&container_path); let _ = std::fs::remove_file(&container_path);
} }
#[tokio::test]
async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() {
let temp_dir = std::env::temp_dir();
let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id()));
let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id()));
if path_standard.exists() {
let _ = std::fs::remove_file(&path_standard);
}
if path_dual.exists() {
let _ = std::fs::remove_file(&path_dual);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let pass_decoy = "OuterDecoyPassphrase2026!";
let pass_hidden = "InnerHiddenVaultPass2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
// 1. Erstelle Standard-Container (Container A)
let salt_std = generate_salt();
let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap();
let dek_std = generate_dek();
let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap();
let db_std = Database::open(&path_standard).unwrap();
db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap();
db_std.checkpoint().unwrap();
// 2. Erstelle Dual-Vault Container (Container B)
let salt_b0 = generate_salt();
let salt_b1 = generate_salt();
let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap();
let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap();
let dek_b0 = generate_dek();
let dek_b1 = generate_dek();
let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap();
let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap();
let db_dual = Database::open(&path_dual).unwrap();
db_dual.init_schema_with_hidden(
&salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0,
Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)),
).unwrap();
db_dual.checkpoint().unwrap();
// 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit
let conn_std = rusqlite::Connection::open(&path_standard).unwrap();
let conn_dual = rusqlite::Connection::open(&path_dual).unwrap();
// A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern
for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] {
let v_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name);
let v_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name);
// Genau 2 Slots in meta
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap();
assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name);
// Genau 2 Root-Nodes in nodes (id=1 und id=2)
let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap();
assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name);
}
drop(conn_std);
drop(conn_dual);
// 4. Dateien in beiden Vaults von Container B anlegen
let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0);
let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1);
let decoy_path = DavPath::new("/family_recipe.txt").unwrap();
let hidden_path = DavPath::new("/private_journal.docx").unwrap();
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap();
f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap();
f_d.flush().await.unwrap();
drop(f_d);
let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap();
f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap();
f_h.flush().await.unwrap();
drop(f_h);
db_dual.checkpoint().unwrap();
// 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite
let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap();
let hidden_node_name: String = conn_dual2.query_row(
"SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0)
).unwrap();
assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!");
assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!");
assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein");
let count_dollar_h: i64 = conn_dual2.query_row(
"SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0)
).unwrap();
assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren");
drop(conn_dual2);
// 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG
let meta_dual = db_dual.read_meta().unwrap();
let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren");
assert_eq!(*auth_decoy.0, *dek_b0);
let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren");
assert_eq!(*auth_hidden.0, *dek_b1);
assert!(meta_dual.authenticate("WrongPassword123!").is_none());
// 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT
// Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden!
let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap();
assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors);
assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden");
assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden");
// Hidden-Prüfung: Ebenfalls gesund!
let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap();
assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors);
assert_eq!(report_hidden.corrupted_chunks, 0);
assert_eq!(report_hidden.orphan_nodes, 0);
// 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten!
export_header_backup(&path_dual, &backup_path).unwrap();
// Zerstöre Header
let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap();
conn_wipe.execute("DELETE FROM meta", []).unwrap();
drop(conn_wipe);
// Stelle wieder her
restore_header_backup(&path_dual, &backup_path).unwrap();
// Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos!
let restored_meta = db_dual.read_meta().unwrap();
let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore");
assert_eq!(res_decoy.2, 0);
let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore");
assert_eq!(res_hidden.2, 1);
// 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört!
let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap();
let brand_new_decoy_pass = "BrandNewDecoyPassword2026!";
restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap();
let rescued_meta = db_dual.read_meta().unwrap();
// Neues Decoy Passwort entsperrt Slot 0
let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass");
assert_eq!(rescued_decoy.2, 0);
assert_eq!(*rescued_decoy.0, *dek_b0);
// Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!)
let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved");
assert_eq!(rescued_hidden.2, 1);
assert_eq!(*rescued_hidden.0, *dek_b1);
// Aufräumen
let _ = std::fs::remove_file(&path_standard);
let _ = std::fs::remove_file(&path_dual);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_sanctum_online_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_backup_src_{}.sanctum", std::process::id()));
let backup_path = temp_dir.join(format!("test_backup_out_{}.sanctum.bak", std::process::id()));
let restored_path = temp_dir.join(format!("test_backup_restored_{}.sanctum", std::process::id()));
for p in [&container_path, &backup_path, &restored_path] {
if p.exists() {
let _ = std::fs::remove_file(p);
}
}
let password = "BackupTestPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 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, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
// Datei schreiben
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/important.txt").unwrap();
let mut file = fs.open(&test_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")).await.unwrap();
file.flush().await.unwrap();
drop(file);
// 1. Online-Live-Backup erstellen
db.online_backup(&backup_path).expect("Online backup should succeed");
assert!(backup_path.exists(), "Backup-Datei muss existieren");
// 2. Original-Container verändern (neue Datei hinzufügen)
let extra_file = DavPath::new("/extra_after_backup.txt").unwrap();
let mut file2 = fs.open(&extra_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
file2.write_bytes(Bytes::from_static(b"After Backup Data")).await.unwrap();
file2.flush().await.unwrap();
drop(file2);
// 3. Restore aus dem Backup in neuen Pfad
Database::restore_from_backup(&backup_path, &restored_path).expect("Restore should succeed");
assert!(restored_path.exists(), "Wiederhergestellter Container muss existieren");
// 4. Verifiziere den wiederhergestellten Container
let restored_db = Database::open(&restored_path).unwrap();
let meta = restored_db.read_meta().unwrap();
let auth = meta.authenticate(password).expect("Passwort muss den wiederhergestellten Container entsperren");
assert_eq!(*auth.0, *dek);
let restored_fs = SanctumFs::new(restored_db.clone(), auth.0, meta.version);
// /important.txt muss existieren und den korrekten Inhalt haben
let mut read_handle = restored_fs.open(&test_file, OpenOptions { read: true, ..Default::default() }).await.unwrap();
let content = read_handle.read_bytes(100).await.unwrap();
assert_eq!(&content[..], b"Sanctum Online Backup Test Data");
drop(read_handle);
// /extra_after_backup.txt darf im Backup-Zustand NICHT existieren
assert!(restored_fs.open(&extra_file, OpenOptions { read: true, ..Default::default() }).await.is_err());
// Integritätsprüfung (FSCK) auf wiederhergestelltem Container
let report = verify_container(&restored_path, Some(&dek), true).unwrap();
assert!(report.is_healthy());
for p in [&container_path, &backup_path, &restored_path] {
let _ = std::fs::remove_file(p);
}
}
#[tokio::test]
async fn test_webdav_loopback_session_token_and_host_validation() {
use dav_server::{fakels::FakeLs, DavHandler};
use sanctum::mount::{is_loopback_host, serve_webdav_loop};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
// 1. Host-Header Unit Validierung
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:8443"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:8443"));
assert!(!is_loopback_host("attacker.com"));
assert!(!is_loopback_host("evil.attacker.com:8443"));
assert!(!is_loopback_host("192.168.1.100"));
assert!(!is_loopback_host("internal.corp"));
// 2. Container und WebDAV Setup
let temp_dir = std::env::temp_dir();
let container_path = temp_dir.join(format!("test_webdav_sec_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "WebDavSecPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 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, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.checkpoint().unwrap();
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let session_token = "deadbeefcafebabe0123456789abcdef";
let token_path_prefix = format!("/{}", session_token);
let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let server_handle = tokio::spawn(serve_webdav_loop(
listener,
dav_server,
token_path_prefix.clone(),
shutdown_rx,
));
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 4. Angriffstest B: DNS-Rebinding mit fremdem Host-Header trotz erratenem Token -> 403 Forbidden
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("GET /{}/ HTTP/1.1\r\nHost: evil.attacker.com:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
"Anfrage mit fremdem Host-Header (DNS Rebinding) muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
resp_str
);
}
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
{
let mut stream = TcpStream::connect(addr).await.unwrap();
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
stream.write_all(req.as_bytes()).await.unwrap();
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.unwrap();
let resp_str = String::from_utf8_lossy(&resp);
assert!(
resp_str.starts_with("HTTP/1.1 200 OK"),
"Legitime OPTIONS-Anfrage mit Session-Token muss 200 OK liefern, erhalten:\n{}",
resp_str
);
assert!(
resp_str.to_lowercase().contains("dav:"),
"Antwort muss WebDAV DAV-Header enthalten"
);
}
// Server ordnungsgemäß beenden & aufräumen
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
let _ = std::fs::remove_file(&container_path);
}
+152
View File
@@ -0,0 +1,152 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use dav_server::{
davpath::DavPath,
fs::{DavFileSystem, OpenOptions},
};
use rand::RngCore;
use sanctum::{
crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
};
/// Live-Crash- und Stresstest: Simuliert harten Verbindungsabbruch und Power-Cut
/// während intensiver paralleler Schreibvorgänge im VFS.
#[tokio::test]
async fn test_live_crash_and_recovery_stress() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "LiveStressPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
time_cost: sanctum::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("DEK wrapping");
// 1. Initialisierung des Containers
{
let db = Database::open(&container_path).expect("Open database");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.expect("Init schema");
db.checkpoint().expect("Initial Checkpoint");
}
// 2. Parallele Schreiblast mit SanctumFs erzeugen
let stop_signal = Arc::new(AtomicBool::new(false));
let db = Database::open(&container_path).expect("Open database for VFS");
let fs = SanctumFs::new(db, dek.clone(), FORMAT_VERSION);
let mut handles = Vec::new();
// Spawn 4 parallele Schreiber
for worker_id in 0..4 {
let fs_clone = fs.clone();
let stop_clone = stop_signal.clone();
let handle = tokio::spawn(async move {
let mut file_idx = 0;
while !stop_clone.load(Ordering::Relaxed) && file_idx < 10 {
let file_path_str = format!("/worker_{}_file_{}.dat", worker_id, file_idx);
let dav_path = DavPath::new(&file_path_str).unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create = true;
opts.truncate = true;
// Datei anlegen
let mut file = match fs_clone.open(&dav_path, opts).await {
Ok(f) => f,
Err(_) => break,
};
// Mehrere 256-KB Blöcke schreiben (über mehrere Chunks hinweg)
let mut payload = vec![0u8; 256 * 1024];
rand::thread_rng().fill_bytes(&mut payload);
for _ in 0..6 {
if stop_clone.load(Ordering::Relaxed) {
break;
}
let _ = file.write_bytes(Bytes::copy_from_slice(&payload)).await;
}
let _ = file.flush().await;
file_idx += 1;
}
});
handles.push(handle);
}
// Lass die Worker 500ms unter Volllast schreiben
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// 3. Simuliere abrupten Prozessabbruch (Hard Kill / Power Cut)
// Wir brechen die Worker hart ab (Cancel) und verwerfen das FS-Handle ohne sauberen Unmount
stop_signal.store(true, Ordering::SeqCst);
for h in handles {
h.abort(); // Simuliert Kill
}
// FS ohne Checkpoint/Drop-Finalisierung freigeben
drop(fs);
// 4. Recovery & Integritätsprüfung nach Crash
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
assert!(
verify_result.is_healthy(),
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
verify_result.errors
);
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
// 5. Konsistentes Weiterarbeiten nach dem Absturz
let db_recovered = Database::open(&container_path).expect("Open database after crash");
let fs_recovered = SanctumFs::new(db_recovered, dek.clone(), FORMAT_VERSION);
// Neue Datei im wiederhergestellten Dateisystem anlegen und lesen
let recovery_test_path = DavPath::new("/post_crash_verification.txt").unwrap();
{
let mut opts = OpenOptions::default();
opts.write = true;
opts.create = true;
opts.truncate = true;
let mut file = fs_recovered
.open(&recovery_test_path, opts)
.await
.expect("Create post-crash file");
file.write_bytes(Bytes::from_static(b"Sanctum Crash Consistency Verified!"))
.await
.expect("Write post crash file");
file.flush().await.expect("Flush post crash file");
}
// Datei wieder einlesen
{
let mut opts = OpenOptions::default();
opts.read = true;
let mut file = fs_recovered
.open(&recovery_test_path, opts)
.await
.expect("Read post-crash file");
let bytes = file.read_bytes(1024).await.expect("Read bytes");
assert_eq!(&bytes[..], b"Sanctum Crash Consistency Verified!");
}
// Sauberes Aufräumen der Testdatei
drop(fs_recovered);
let _ = std::fs::remove_file(&container_path);
}
+84
View File
@@ -0,0 +1,84 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::mount::is_loopback_host;
use sanctum::storage::Database;
#[test]
fn test_is_loopback_host_comprehensive() {
// Gültige IPv4 Loopback Varianten
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:80"));
assert!(is_loopback_host("127.0.0.1:8080"));
assert!(is_loopback_host("127.0.0.1:65535"));
// Gültige Hostname Loopback Varianten
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:80"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("LOCALHOST"));
assert!(is_loopback_host("LocalHost:9000"));
// Gültige IPv6 Loopback Varianten
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:80"));
assert!(is_loopback_host("[::1]:8443"));
assert!(is_loopback_host("::1"));
// DNS-Rebinding & Spoofing Angriffe (MÜSSEN abgewiesen werden)
assert!(!is_loopback_host("127.0.0.1.attacker.com"));
assert!(!is_loopback_host("localhost.attacker.com"));
assert!(!is_loopback_host("localhost.evil.org:8080"));
assert!(!is_loopback_host("notlocalhost"));
assert!(!is_loopback_host("attacker.com"));
assert!(!is_loopback_host("attacker.com:127001"));
assert!(!is_loopback_host("192.168.1.1"));
assert!(!is_loopback_host("10.0.0.1"));
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host(""));
assert!(!is_loopback_host(" "));
assert!(!is_loopback_host("foo:bar:baz"));
}
#[test]
fn test_wal_and_shm_cleanup_on_close() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("WalCleanupPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
let _ = db.create_node(1, "test_file.txt", false).unwrap();
db.checkpoint().unwrap();
drop(db);
// Bereinigungslogik (wie in mount.rs unmount) ausführen
let base_os = container_path.as_os_str().to_os_string();
let mut wal = base_os.clone();
wal.push("-wal");
let _ = std::fs::remove_file(&wal);
let mut shm = base_os;
shm.push("-shm");
let _ = std::fs::remove_file(&shm);
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
let _ = std::fs::remove_file(&container_path);
}
+62
View File
@@ -0,0 +1,62 @@
use sanctum::sync::validate_node_name;
#[test]
fn test_validate_node_name_rejections() {
// 1. Leere Namen
assert!(validate_node_name("").is_err());
// 2. Relative Verzeichnisreferenzen
assert!(validate_node_name(".").is_err());
assert!(validate_node_name("..").is_err());
// 3. Pfadtrenner
assert!(validate_node_name("foo/bar").is_err());
assert!(validate_node_name("foo\\bar").is_err());
assert!(validate_node_name("../evil.exe").is_err());
assert!(validate_node_name("..\\evil.exe").is_err());
assert!(validate_node_name("/root_file").is_err());
// 4. Null-Bytes
assert!(validate_node_name("test\0file.txt").is_err());
// 5. Steuerzeichen (< 0x20)
assert!(validate_node_name("line\nbreak.txt").is_err());
assert!(validate_node_name("carriage\rreturn.txt").is_err());
assert!(validate_node_name("tab\tseparated.txt").is_err());
assert!(validate_node_name("control\x01char.txt").is_err());
// 6. Windows reservierte Gerätenamen
let reserved_names = [
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
];
for res in reserved_names {
assert!(
validate_node_name(res).is_err(),
"Reservierter Name '{}' muss abgewiesen werden",
res
);
}
}
#[test]
fn test_validate_node_name_accepted() {
let valid_names = [
"document.pdf",
"my_notes_2026.txt",
"archive.tar.gz",
"spaces are allowed.docx",
"unicode_äöü_ß_test.rs",
"hyphens-and_underscores.json",
"file (1).jpg",
"normal_file",
];
for valid in valid_names {
assert!(
validate_node_name(valid).is_ok(),
"Gültiger Name '{}' sollte akzeptiert werden",
valid
);
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::collections::HashSet;
use std::path::PathBuf;
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id));
path
}
#[test]
fn test_carrier_protection_in_storage_and_sync() {
let path = temp_db_path("carrier_sec");
let carrier_name = "secret_carrier.iso";
let carrier_size_bytes = 2 * 1024 * 1024; // 2 MB
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
let created_cid = db
.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
assert_eq!(created_cid, Some(carrier_node_id));
// 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
let del_res = db.delete_node(carrier_node_id);
assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen");
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0);
assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen");
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
// Der Carrier darf unter keinen Umständen gelöscht werden!
let local_paths = HashSet::new();
let mut stats = SyncStats::default();
let orphan_res = delete_orphans_in_vault(
&db,
0, // Decoy Vault ID
&dek_0,
1, // Root-Knoten
"",
&local_paths,
false, // kein dry_run
true, // quiet
&mut stats,
);
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
let _ = std::fs::remove_file(&path);
}
+241
View File
@@ -0,0 +1,241 @@
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION,
};
use sanctum::storage::Database;
use sanctum::sync::{run_sync, SyncDirection, SyncOptions};
fn create_test_container(path: &Path) -> (Database, [u8; 32]) {
if path.exists() {
let _ = fs::remove_file(path);
}
let db = Database::open(path).expect("Open database");
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
time_cost: sanctum::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
(db, *dek)
}
#[test]
fn test_sync_push_and_pull_basic() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_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();
fs::create_dir_all(source_dir.join("sub")).unwrap();
// Testdateien anlegen
fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap();
fs::write(source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000]).unwrap();
let (db, dek) = create_test_container(&container_path);
// 1. Push
let mut opts = SyncOptions::default();
opts.direction = SyncDirection::Push;
opts.quiet = true;
let stats = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push");
assert_eq!(stats.files_scanned, 2);
assert_eq!(stats.files_transferred, 2);
assert_eq!(stats.files_skipped, 0);
// 2. Erneuter Push (Fast check / Delta): Muss 0 übertragen, 2 überspringen
let stats_delta = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push delta");
assert_eq!(stats_delta.files_transferred, 0);
assert_eq!(stats_delta.files_skipped, 2);
// 3. Dry-Run mit neuer Datei
fs::write(source_dir.join("new_file.txt"), b"Brand new file").unwrap();
let mut dry_opts = opts.clone();
dry_opts.dry_run = true;
let stats_dry = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/Backup",
&dry_opts,
).expect("Sync push dry-run");
assert_eq!(stats_dry.files_transferred, 1);
assert_eq!(stats_dry.files_skipped, 2);
// Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert
let node = db.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek).unwrap();
assert!(node.is_none());
// 4. Pull
let mut pull_opts = SyncOptions::default();
pull_opts.direction = SyncDirection::Pull;
pull_opts.quiet = true;
let stats_pull = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
"/Backup",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull");
assert_eq!(stats_pull.files_transferred, 2);
// Inhalt vergleichen
let c1 = fs::read(target_dir.join("file1.txt")).unwrap();
assert_eq!(c1, b"Hello Sanctum Sync!");
let c2 = fs::read(target_dir.join("sub").join("file2.bin")).unwrap();
assert_eq!(c2, vec![0x42u8; 100_000]);
// Aufräumen
let _ = fs::remove_dir_all(&temp_root);
}
#[test]
fn test_sync_multi_megabyte_large_file() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_large.sanctum");
let source_dir = temp_root.join("source");
let target_dir = temp_root.join("restored");
fs::create_dir_all(&source_dir).unwrap();
// 3.5 MB große Datei erzeugen (geht über 4 Chunks: 0, 1, 2, 3)
let large_file_path = source_dir.join("large_payload.bin");
let mut large_file = File::create(&large_file_path).unwrap();
let chunk_sample = vec![0xA5u8; CHUNK_SIZE];
for _ in 0..3 {
large_file.write_all(&chunk_sample).unwrap();
}
large_file.write_all(&vec![0x5Au8; 512 * 1024]).unwrap(); // 3.5 MB
large_file.flush().unwrap();
drop(large_file);
let (db, dek) = create_test_container(&container_path);
// Push
let mut opts = SyncOptions::default();
opts.direction = SyncDirection::Push;
opts.quiet = true;
let stats = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/LargeTest",
&opts,
).expect("Sync push large");
assert_eq!(stats.files_transferred, 1);
assert_eq!(stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024);
// Pull
let mut pull_opts = SyncOptions::default();
pull_opts.direction = SyncDirection::Pull;
pull_opts.quiet = true;
run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
"/LargeTest",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull large");
let restored = fs::read(target_dir.join("large_payload.bin")).unwrap();
let original = fs::read(&large_file_path).unwrap();
assert_eq!(restored.len(), original.len());
assert_eq!(restored, original);
let _ = fs::remove_dir_all(&temp_root);
}
#[test]
fn test_sync_delete_and_exclude_flags() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_flags.sanctum");
let source_dir = temp_root.join("source");
fs::create_dir_all(&source_dir).unwrap();
fs::write(source_dir.join("keep.txt"), b"Keep this").unwrap();
fs::write(source_dir.join("remove_me.txt"), b"Will be deleted later").unwrap();
fs::write(source_dir.join("ignore.tmp"), b"Temporary file").unwrap();
let (db, dek) = create_test_container(&container_path);
let mut opts = SyncOptions::default();
opts.direction = SyncDirection::Push;
opts.exclude_patterns = vec!["*.tmp".to_string()];
opts.quiet = true;
// 1. Initialer Push mit Exclude
let stats = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt
assert!(db.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek).unwrap().is_none());
// 2. Lokale Datei löschen und Sync mit --delete ausführen
fs::remove_file(source_dir.join("remove_me.txt")).unwrap();
opts.delete = true;
let stats_del = run_sync(
&db,
0,
&dek,
FORMAT_VERSION,
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
assert_eq!(stats_del.files_deleted, 1);
assert!(db.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek).unwrap().is_none());
assert!(db.resolve_path_in_vault("/Files/keep.txt", 0, &dek).unwrap().is_some());
let _ = fs::remove_dir_all(&temp_root);
}
+216
View File
@@ -0,0 +1,216 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::storage::Database;
#[test]
fn test_reject_arbitrary_sqlite_database() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_foreign_sqlite_{}.db", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
.unwrap();
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
}
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
let res = Database::open(&db_path);
match res {
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("kein gültiger Sanctum-Container"),
"Fehler muss ungültigen Container abweisen: {msg}"
);
}
Ok(_) => panic!("Fremde SQLite-DB ohne meta-Tabelle muss abgelehnt werden"),
}
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_reject_sqlite_database_with_invalid_magic() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_invalid_magic_{}.db", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
// Erstelle SQLite DB mit 'meta' Tabelle, aber falscher Magic
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, 2, ?2, '{}', ?3, ?4, ?5)",
rusqlite::params![
b"EVIL_MAGIC",
&[0u8; 16][..],
&[0u8; 40][..],
&[0u8; 12][..],
&[0u8; 16][..]
],
)
.unwrap();
}
let res = Database::open(&db_path);
match res {
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("kein gültiger Sanctum-Container"),
"Fehler muss ungültigen Magic-Header monieren: {msg}"
);
}
Ok(_) => panic!("Container mit ungültigen Magic Bytes muss abgelehnt werden"),
}
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_reject_out_of_bounds_kdf_params() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_oob_kdf_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
// 1. Initialisiere gültigen Container
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.checkpoint().unwrap();
drop(db);
// 2. Manipuliere KDF-Parameter in der meta-Tabelle auf gefährliche Werte (DoS: 16 GiB Memory)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE meta SET kdf_params = '{\"memory_cost\": 16777216, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
[],
)
.unwrap();
}
// 3. Öffnen und read_slots() aufrufen: muss mit Kdf-Param-Fehler abbrechen
let db_reopened = Database::open(&db_path).unwrap();
let slots_res = db_reopened.read_slots();
match slots_res {
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("KDF-Parameter"),
"Fehler muss KDF-Parameter monieren: {msg}"
);
}
Ok(_) => panic!("Überhöhte KDF-Parameter müssen fail-closed abgewehrt werden"),
}
// 4. Manipuliere KDF-Parameter auf zu schwache Werte (< MIN_MEMORY_COST_KIB)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE meta SET kdf_params = '{\"memory_cost\": 1024, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
[],
)
.unwrap();
}
let slots_res2 = db_reopened.read_slots();
match slots_res2 {
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("KDF-Parameter"),
"Fehler muss KDF-Parameter monieren: {msg}"
);
}
Ok(_) => panic!("Zu schwache KDF-Parameter müssen fail-closed abgewehrt werden"),
}
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_reject_corrupted_slot_lengths() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.checkpoint().unwrap();
drop(db);
// Manipuliere Salt-Länge auf 8 Byte statt 16 Byte
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE meta SET kdf_salt = ?1 WHERE slot_id = 0",
[&[0u8; 8][..]],
)
.unwrap();
}
let db_reopened = Database::open(&db_path).unwrap();
let res = db_reopened.read_slots();
match res {
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("Salt-Länge"),
"Fehler muss fehlerhafte Salt-Länge monieren: {msg}"
);
}
Ok(_) => panic!("Ungültige Salt-Länge muss abgewiesen werden"),
}
let _ = std::fs::remove_file(&db_path);
}
+64
View File
@@ -0,0 +1,64 @@
use sanctum::upgrade::{
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
};
#[test]
fn test_minisign_signature_valid() {
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
let sig_text = "\
untrusted comment: signature from minisign secret key\n\
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
}
#[test]
fn test_minisign_signature_tampered_payload() {
let tampered_payload = b"Hello Sanctum Release 0.7.0 Tampered Payload!\n";
let sig_text = "\
untrusted comment: signature from minisign secret key\n\
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
}
#[test]
fn test_minisign_signature_wrong_key() {
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
let sig_text = "\
untrusted comment: signature from minisign secret key\n\
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
// Ein anderer, unautorisierter Public Key
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
let res = verify_minisign_signature(payload, sig_text, other_key);
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
}
#[test]
fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
let options = UpgradeOptions {
check_only: true,
yes: false,
force: false,
base_url: "https://evil-attacker.org/sanctum".into(),
insecure_url: false,
};
let res = run_upgrade(&options);
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
);
}
+160
View File
@@ -0,0 +1,160 @@
use std::fs::File;
use std::io::Write;
use std::path::Path;
use sanctum::upgrade::{
detect_package_manager_from_path, is_newer_version, parse_checksum_for_asset,
parse_clean_version, GiteaRelease, PackageManager,
};
use sha2::{Digest, Sha256};
#[test]
fn test_parse_checksum_for_asset() {
let sample_checksums = r#"
# Offizielle SHA-256 Prüfsummen für Sanctum Release
2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e sanctum-v0.5.0-windows-x86_64.zip
e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63 sanctum.exe
557dd84f339336899fdd67e69d894dcd6e32f3e0bff2ae8a3057df596eddf4b4 sanctum-v0.5.0-linux-x86_64.tar.gz
2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d *sanctum
"#;
// Windows Binary
let win_hash = parse_checksum_for_asset(sample_checksums, "sanctum.exe");
assert_eq!(
win_hash.as_deref(),
Some("e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63")
);
// Linux Binary (mit führendem Asterisk im Format)
let linux_hash = parse_checksum_for_asset(sample_checksums, "sanctum");
assert_eq!(
linux_hash.as_deref(),
Some("2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d")
);
// Nicht existentes Asset
let non_existent = parse_checksum_for_asset(sample_checksums, "nonexistent.exe");
assert_eq!(non_existent, None);
}
#[test]
fn test_semver_comparisons() {
// Normaler Versionssprung
assert!(is_newer_version("0.5.0", "0.6.0"));
assert!(is_newer_version("0.5.0", "v0.6.0"));
assert!(is_newer_version("v0.5.0", "V0.5.1"));
assert!(is_newer_version("0.5.0", "1.0.0"));
// Gleiche Version
assert!(!is_newer_version("0.6.0", "0.6.0"));
assert!(!is_newer_version("0.6.0", "v0.6.0"));
assert!(!is_newer_version("v0.6.0", "0.6.0"));
// Ältere Version
assert!(!is_newer_version("0.6.0", "0.5.0"));
assert!(!is_newer_version("0.6.0", "v0.4.1"));
// Parsing mit führenden 'v' / 'V'
let v1 = parse_clean_version("v0.6.0").expect("Parse v0.6.0");
let v2 = parse_clean_version("V1.2.3").expect("Parse V1.2.3");
assert_eq!(v1.major, 0);
assert_eq!(v1.minor, 6);
assert_eq!(v2.major, 1);
assert_eq!(v2.minor, 2);
}
#[test]
fn test_package_manager_detection() {
// Scoop Pfade
let scoop_path = Path::new(r"C:\Users\harald\scoop\apps\sanctum\current\sanctum.exe");
assert_eq!(
detect_package_manager_from_path(scoop_path),
Some(PackageManager::Scoop)
);
let scoop_shim = Path::new(r"C:\Users\harald\scoop\shims\sanctum.exe");
assert_eq!(
detect_package_manager_from_path(scoop_shim),
Some(PackageManager::Scoop)
);
// Winget Pfade
let winget_path = Path::new(
r"C:\Users\harald\AppData\Local\Microsoft\WinGet\Packages\HaraldPansi.Sanctum_Microsoft.Winget.Source_8wekyb3d8bbwe\sanctum.exe",
);
assert_eq!(
detect_package_manager_from_path(winget_path),
Some(PackageManager::Winget)
);
// Standard Standalone Pfade (kein Paketmanager)
let standalone = Path::new(r"C:\Tools\sanctum\sanctum.exe");
assert_eq!(detect_package_manager_from_path(standalone), None);
let linux_usr = Path::new("/usr/local/bin/sanctum");
assert_eq!(detect_package_manager_from_path(linux_usr), None);
}
#[test]
fn test_hash_integrity_calculation() {
let test_file = std::env::temp_dir().join(format!("sanctum_test_{}.bin", std::process::id()));
let payload = b"Sanctum Standalone Binary Payload for Integrity Testing";
{
let mut f = File::create(&test_file).expect("create file");
f.write_all(payload).expect("write");
}
let mut hasher = Sha256::new();
hasher.update(payload);
let expected = hex::encode(hasher.finalize());
// Datei erneut lesen und hashen
let mut reader = File::open(&test_file).expect("open");
let mut file_hasher = Sha256::new();
std::io::copy(&mut reader, &mut file_hasher).expect("copy");
let calculated = hex::encode(file_hasher.finalize());
assert_eq!(expected, calculated);
let _ = std::fs::remove_file(&test_file);
}
#[test]
fn test_json_deserialization_of_gitea_release() {
let gitea_json = r##"{
"id": 27,
"tag_name": "v0.5.0",
"name": "Sanctum v0.5.0 (Windows x86_64)",
"body": "Release v0.5.0 - sanctum sync",
"html_url": "https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.0",
"published_at": "2026-09-16T10:44:18+02:00",
"assets": [
{
"id": 38,
"name": "sanctum.exe",
"size": 5467136,
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum.exe"
},
{
"id": 40,
"name": "sanctum",
"size": 5033952,
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum"
},
{
"id": 41,
"name": "SHA256SUMS.txt",
"size": 563,
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/SHA256SUMS.txt"
}
]
}"##;
let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
assert_eq!(release.id, 27);
assert_eq!(release.tag_name, "v0.5.0");
assert_eq!(release.assets.len(), 3);
assert_eq!(release.assets[0].name, "sanctum.exe");
assert_eq!(release.assets[1].name, "sanctum");
assert_eq!(release.assets[2].name, "SHA256SUMS.txt");
}