Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdb30147b | ||
|
|
436790abf0 | ||
|
|
80a3bd1911 | ||
|
|
2da1ea55a9 | ||
|
|
2e4cf1cef0 | ||
|
|
1048253497 | ||
|
|
aa65d96434 | ||
|
|
3f3f8f2730 | ||
|
|
650cfc7cd4 | ||
|
|
9d1a4d3ad7 | ||
|
|
aa25719c0d | ||
|
|
34378998c4 | ||
|
|
4ef0c414fa | ||
|
|
1f3afb1e52 | ||
|
|
5f2040f8bf | ||
|
|
da2ddbd6ab | ||
|
|
46f976b353 | ||
|
|
6cb48f55d9 | ||
|
|
7ed0f51fb1 | ||
|
|
dae0b3b7c3 | ||
|
|
541190cff4 | ||
|
|
99813ae2a3 | ||
|
|
90cf4927af |
@@ -51,6 +51,35 @@ jobs:
|
|||||||
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
||||||
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Sign Checksums with Minisign
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
run: |
|
||||||
|
if (-not $env:MINISIGN_SECRET_KEY) {
|
||||||
|
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) {
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip" -OutFile "minisign.zip"
|
||||||
|
Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin"
|
||||||
|
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
|
||||||
|
} else {
|
||||||
|
$MinisignExe = "minisign"
|
||||||
|
}
|
||||||
|
|
||||||
|
$KeyFile = "sanctum-ci-release.key"
|
||||||
|
[System.IO.File]::WriteAllText($KeyFile, $env:MINISIGN_SECRET_KEY)
|
||||||
|
try {
|
||||||
|
& $MinisignExe -S -s $KeyFile -m "dist/SHA256SUMS.txt" -W -x "dist/SHA256SUMS.txt.minisig"
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "dist/SHA256SUMS.txt.minisig")) {
|
||||||
|
Write-Error "Minisign-Signierung fehlgeschlagen!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-Item $KeyFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -58,6 +87,7 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/SHA256SUMS.txt
|
dist/SHA256SUMS.txt
|
||||||
|
dist/SHA256SUMS.txt.minisig
|
||||||
target/release/sanctum.exe
|
target/release/sanctum.exe
|
||||||
body_path: CHANGELOG.md
|
body_path: CHANGELOG.md
|
||||||
draft: false
|
draft: false
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
.env.*
|
.env.*
|
||||||
*.token
|
*.token
|
||||||
.token
|
.token
|
||||||
|
*.key
|
||||||
|
sanctum-release.key
|
||||||
|
|||||||
+159
@@ -5,6 +5,165 @@ 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.1] - 2026-09-18
|
||||||
|
|
||||||
|
### Security Patch Release (Closing Gaps R-01 to R-06)
|
||||||
|
Dieses Release schließt sechs identifizierte Restlücken (R-01 bis R-06) aus dem Security-Review von v0.7.0 und härtet Authentifizierung, Prozessisolation, Carrier-Integrität und CI/CD-Sicherheit.
|
||||||
|
|
||||||
|
#### Security & Architecture
|
||||||
|
- **R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code**:
|
||||||
|
- `LEGAL.md`: Das „100% Offline“-Versprechen wurde präzisiert. Es wird wahrheitsgemäß dokumentiert, dass keine Telemetrie oder stilles Phone-Home existiert, der Befehl `sanctum upgrade` (und `--check`) jedoch bei explizitem Nutzeraufruf die Release-API des konfigurierten Gitea-Hosts anspricht.
|
||||||
|
- Entfernung aller verbliebenen Formulierungen über unnachweisbare Abstreitbarkeit ("Plausible Deniability") oder RIPA 49.
|
||||||
|
- Neutralisierung der Nutzer- und Entwicklerdokumentation bezüglich der Alibi-Trägerdatei im Decoy-Vault.
|
||||||
|
- **R-02: Lückenloser Schutz der Trägerdatei in VFS und Sync**:
|
||||||
|
- `SanctumFs::copy`: Das Duplizieren der Trägerdatei als Quelle sowie das Überschreiben als Ziel wird nun mit `FsError::Forbidden` fail-closed abgewehrt.
|
||||||
|
- `sanctum sync` (Pull): `sync_single_file_to_host` verweigert den Abzug der Trägerdatei via `assert_not_carrier`. Der rekursive Pull-Vorgang (`collect_and_pull_dir`) überspringt die Trägerdatei vollständig und spiegelt sie nicht auf den Host.
|
||||||
|
- `sanctum sync` (Push): Verhindert das Überschreiben der bestehenden Trägerdatei durch Push-Operationen.
|
||||||
|
- **R-03: Vereinheitlichte Validierung von Dateinamen (Path-Traversal & Gerätenamen)**:
|
||||||
|
- Auslagerung der kanonischen Prüffunktion `validate_node_name` in das gemeinsame Modul `src/pathutil.rs`.
|
||||||
|
- Durchgängige Durchsetzung in `storage.rs` (`create_node_in_vault`, `rename_node_in_vault`) und `vfs.rs` (`create_node`, `rename_node`).
|
||||||
|
- Strikte Zurückweisung von Pfadtrennern, Null-Bytes, Steuerzeichen, Verzeichnisreferenzen (`.`, `..`) und reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
||||||
|
- **R-04: Fail-Closed Release-Packaging und CI/CD-Signaturpflicht**:
|
||||||
|
- `package-release.ps1` und `package-release-linux.ps1`: Das Fehlen von `minisign.exe` oder `sanctum-release.key` bricht die Paketerstellung sofort mit `exit 1` ab (kein Warning-and-continue mehr).
|
||||||
|
- `publish-release.ps1`: Bricht die Veröffentlichung mit Fehler ab, wenn `SHA256SUMS.txt.minisig` fehlt.
|
||||||
|
- `.gitea/workflows/release.yaml`: Automatischer Minisign-Signaturschritt mittels Secret `MINISIGN_SECRET_KEY` und Pflicht-Upload von `SHA256SUMS.txt.minisig` zu jedem Gitea-Release.
|
||||||
|
- **R-05: Beseitigung von Session-Token im Argumentvektor (argv)**:
|
||||||
|
- Windows Netzlaufwerk-Mounting: Vollständige Umstellung von externem `net use` Kindprozess auf die in-process Win32-APIs `WNetAddConnection2W` und `WNetCancelConnection2W` (aus `mpr.dll`). Das Session-Token wird als Passwort übergeben; die Remote-URL `http://127.0.0.1:<port>/` enthält kein Token mehr im Prozess-Argumentvektor oder in der Prozessliste.
|
||||||
|
- Linux: Beseitigung des Tokens aus `gio mount` Argumenten.
|
||||||
|
- WebDAV HTTP-Middleware: Unterstützung von HTTP Basic Auth (`Authorization: Basic <base64>`), `X-Sanctum-Token`-Header und Pfad-Präfix-Fallback (`/<token>/`). Unauthentifizierte Anfragen werden mit standardkonformem `401 Unauthorized` und `WWW-Authenticate: Basic realm="Sanctum"` beantwortet; ungültige Host-Header mit `403 Forbidden`.
|
||||||
|
- **R-06: Speicher-Hygiene bei Notfallschlüsseln**:
|
||||||
|
- In `src/main.rs` wird die Umgebungsvariable `SANCTUM_RECOVERY_KEY` nach dem erfolgreichen Einlesen sofort mittels `std::env::remove_var` aus dem Prozessspeicher entfernt.
|
||||||
|
|
||||||
|
## [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
|
## [0.3.0] - 2026-09-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+170
-1
@@ -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,11 +1457,12 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.3.0"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"base64",
|
||||||
"bip39",
|
"bip39",
|
||||||
"bytes",
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1393,16 +1474,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 +1499,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 +1570,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 +1679,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 +1908,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 +2035,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
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.3.0"
|
version = "0.7.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||||
@@ -12,6 +12,7 @@ keywords = ["encryption", "container", "windows", "webdav", "security"]
|
|||||||
categories = ["cryptography", "command-line-utilities", "filesystem"]
|
categories = ["cryptography", "command-line-utilities", "filesystem"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
rpassword = "7.3"
|
rpassword = "7.3"
|
||||||
argon2 = { version = "0.5", features = ["password-hash"] }
|
argon2 = { version = "0.5", features = ["password-hash"] }
|
||||||
@@ -36,11 +37,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
@@ -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
|
||||||
|
```
|
||||||
@@ -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 unnachweisbare Abstreitbarkeit**: 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 und ist **nicht** als Schutzschild gegen behördliche Herausgabeanordnungen 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. Es gibt keine automatischen Hintergrund-Pings oder stillen Verbindungen.
|
||||||
|
* **Keine unaufgeforderten Netzwerkverbindungen**: Sanctum stellt standardmäßig keine ausgehenden Internetverbindungen her. Die einzige bewusste Ausnahme ist der Befehl `sanctum upgrade` (bzw. `sanctum upgrade --check`), welcher ausschließlich nach expliziter manueller Eingabe durch den Anwender Kontakt mit dem konfigurierten Gitea-Host aufnimmt. Jedes Update-Manifest wird dabei kryptografisch gegen den fest eingebetteten Minisign-Herstellerschlüssel verifiziert.
|
||||||
|
* **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
@@ -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.
|
||||||
|
```
|
||||||
@@ -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` (~5.3 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 juristisch unnachweisbare Abstreitbarkeit 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 30–50% steigern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,13 +266,31 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Erzeugt:
|
Erzeugt:
|
||||||
- `dist/sanctum-v0.3.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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
untrusted comment: minisign public key E735BF824F9856AC
|
||||||
|
RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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
|
||||||
|
} else {
|
||||||
|
Write-Error "Minisign-Signierung fehlgeschlagen!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "minisign.exe nicht gefunden ($MinisignExe)! Release-Erstellung abgebrochen."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$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."
|
||||||
@@ -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,45 @@ $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-Error "minisign.exe nicht gefunden ($MinisignExe)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# 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 +135,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"
|
||||||
|
|||||||
+55
-12
@@ -27,6 +27,12 @@ if (-not (Test-Path $ZipFile)) {
|
|||||||
& (Join-Path $ScriptDir "package-release.ps1")
|
& (Join-Path $ScriptDir "package-release.ps1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||||
|
if (-not (Test-Path $SigFile)) {
|
||||||
|
Write-Error "Obligatorische Minisign-Signaturdatei '$SigFile' nicht gefunden! Ein Release ohne kryptografische Signatur darf nicht veroeffentlicht werden."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# .env-Datei laden falls vorhanden (ist in .gitignore)
|
# .env-Datei laden falls vorhanden (ist in .gitignore)
|
||||||
$EnvFile = Join-Path $ProjectRoot ".env"
|
$EnvFile = Join-Path $ProjectRoot ".env"
|
||||||
if (Test-Path $EnvFile) {
|
if (Test-Path $EnvFile) {
|
||||||
@@ -106,22 +112,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
|
||||||
|
|||||||
@@ -345,6 +345,13 @@ impl CarrierFsInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for CarrierFsInner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32);
|
||||||
|
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault).
|
/// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CarrierFs {
|
pub struct CarrierFs {
|
||||||
@@ -380,6 +387,10 @@ impl CarrierFs {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Forensischer RAM-Paging-Schutz via VirtualLock
|
||||||
|
crate::windows::lock_memory(dek_outer.as_ptr(), 32);
|
||||||
|
crate::windows::lock_memory(dek_inner.as_ptr(), 32);
|
||||||
|
|
||||||
let inner = CarrierFsInner {
|
let inner = CarrierFsInner {
|
||||||
db,
|
db,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
|
|||||||
+256
-30
@@ -40,12 +40,74 @@ impl Default for KdfParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const MIN_MEMORY_COST_KIB: u32 = 19_456;
|
||||||
|
pub const MAX_MEMORY_COST_KIB: u32 = 262_144;
|
||||||
|
pub const MIN_TIME_COST: u32 = 2;
|
||||||
|
pub const MAX_TIME_COST: u32 = 8;
|
||||||
|
pub const MIN_PARALLELISM: u32 = 1;
|
||||||
|
pub const MAX_PARALLELISM: u32 = 8;
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 12;
|
||||||
|
|
||||||
|
/// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04).
|
||||||
|
pub fn validate_kdf_params(params: &KdfParams) -> Result<()> {
|
||||||
|
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(¶ms.memory_cost) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)",
|
||||||
|
params.memory_cost,
|
||||||
|
MIN_MEMORY_COST_KIB,
|
||||||
|
MAX_MEMORY_COST_KIB
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !(MIN_TIME_COST..=MAX_TIME_COST).contains(¶ms.time_cost) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})",
|
||||||
|
params.time_cost,
|
||||||
|
MIN_TIME_COST,
|
||||||
|
MAX_TIME_COST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(¶ms.parallelism) {
|
||||||
|
bail!(
|
||||||
|
"KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})",
|
||||||
|
params.parallelism,
|
||||||
|
MIN_PARALLELISM,
|
||||||
|
MAX_PARALLELISM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10).
|
||||||
|
pub fn validate_password(password: &str) -> Result<()> {
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LENGTH {
|
||||||
|
bail!(
|
||||||
|
"Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).",
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
password.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist.
|
||||||
|
pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
|
||||||
|
if pass0 == pass1 {
|
||||||
|
bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!");
|
||||||
|
} else if pass0.starts_with(pass1) || pass1.starts_with(pass0) {
|
||||||
|
bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit.");
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
|
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
|
||||||
pub fn derive_kek(
|
pub fn derive_kek(
|
||||||
password: &str,
|
password: &str,
|
||||||
salt: &[u8],
|
salt: &[u8],
|
||||||
params: &KdfParams,
|
params: &KdfParams,
|
||||||
) -> Result<Zeroizing<[u8; 32]>> {
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
validate_kdf_params(params)?;
|
||||||
|
|
||||||
let argon2_params = Params::new(
|
let argon2_params = Params::new(
|
||||||
params.memory_cost,
|
params.memory_cost,
|
||||||
params.time_cost,
|
params.time_cost,
|
||||||
@@ -56,10 +118,13 @@ pub fn derive_kek(
|
|||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
let mut kek = Zeroizing::new([0u8; 32]);
|
let mut kek = Zeroizing::new([0u8; 32]);
|
||||||
|
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
|
||||||
|
|
||||||
argon2
|
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
|
||||||
.hash_password_into(password.as_bytes(), salt, &mut *kek)
|
if let Err(e) = res {
|
||||||
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
|
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
|
||||||
|
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(kek)
|
Ok(kek)
|
||||||
}
|
}
|
||||||
@@ -67,6 +132,7 @@ pub fn derive_kek(
|
|||||||
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
|
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
|
||||||
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
||||||
let mut dek = Zeroizing::new([0u8; 32]);
|
let mut dek = Zeroizing::new([0u8; 32]);
|
||||||
|
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
||||||
OsRng.fill_bytes(&mut *dek);
|
OsRng.fill_bytes(&mut *dek);
|
||||||
dek
|
dek
|
||||||
}
|
}
|
||||||
@@ -175,8 +241,7 @@ pub fn wrap_slot1_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 Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
|
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload) für Dummy-Slot-Längenparität im Header.
|
||||||
/// 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; 72];
|
let mut wrapped_dek = vec![0u8; 72];
|
||||||
let mut nonce = [0u8; 12];
|
let mut nonce = [0u8; 12];
|
||||||
@@ -200,6 +265,17 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
|||||||
aad
|
aad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
|
||||||
|
pub fn set_allow_legacy_names(allow: bool) {
|
||||||
|
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_legacy_names() -> bool {
|
||||||
|
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
|
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
|
||||||
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
|
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
|
||||||
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
|
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
|
||||||
@@ -207,12 +283,16 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
|||||||
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
||||||
let mut nonce_bytes = [0u8; 12];
|
let mut nonce_bytes = [0u8; 12];
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
|
let cipher = match Aes256Gcm::new_from_slice(dek) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
let mut buffer = name.as_bytes().to_vec();
|
let mut buffer = name.as_bytes().to_vec();
|
||||||
let aad = build_name_aad(parent_id);
|
let aad = build_name_aad(parent_id);
|
||||||
let tag = cipher
|
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
|
||||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
|
Ok(t) => t,
|
||||||
.expect("Name encryption");
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||||
combined.extend_from_slice(&nonce_bytes);
|
combined.extend_from_slice(&nonce_bytes);
|
||||||
combined.extend_from_slice(tag.as_slice());
|
combined.extend_from_slice(tag.as_slice());
|
||||||
@@ -221,9 +301,9 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
||||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
|
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
|
||||||
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
|
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
|
||||||
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
|
||||||
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
||||||
if let Some(rest) = stored.strip_prefix("$h$") {
|
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||||
let parts: Vec<&str> = rest.split('$').collect();
|
let parts: Vec<&str> = rest.split('$').collect();
|
||||||
@@ -250,7 +330,8 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
return String::from_utf8(buffer).ok();
|
return String::from_utf8(buffer).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
|
// 2. Fallback: Statische AAD nur wenn explizit aktiviert (S-10)
|
||||||
|
if allow_legacy {
|
||||||
let mut buffer_legacy = ct_bytes;
|
let mut buffer_legacy = ct_bytes;
|
||||||
if cipher
|
if cipher
|
||||||
.decrypt_in_place_detached(
|
.decrypt_in_place_detached(
|
||||||
@@ -266,6 +347,7 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +375,8 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
return String::from_utf8(buffer).ok();
|
return String::from_utf8(buffer).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Alte statische AAD für bestehende Container
|
// 2. Fallback: Alte statische AAD nur wenn explizit aktiviert (S-10)
|
||||||
|
if allow_legacy {
|
||||||
let mut buffer_legacy = ct.to_vec();
|
let mut buffer_legacy = ct.to_vec();
|
||||||
if cipher
|
if cipher
|
||||||
.decrypt_in_place_detached(
|
.decrypt_in_place_detached(
|
||||||
@@ -310,9 +393,14 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
||||||
|
decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names())
|
||||||
|
}
|
||||||
|
|
||||||
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
|
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
|
||||||
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||||
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
||||||
@@ -320,16 +408,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 {
|
||||||
@@ -440,7 +633,9 @@ pub fn decrypt_chunk(
|
|||||||
if payload.len() < 4 {
|
if payload.len() < 4 {
|
||||||
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
|
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
|
||||||
}
|
}
|
||||||
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
|
let mut size_bytes = [0u8; 4];
|
||||||
|
size_bytes.copy_from_slice(&payload[0..4]);
|
||||||
|
let uncompressed_size = u32::from_le_bytes(size_bytes) as usize;
|
||||||
if uncompressed_size > CHUNK_SIZE {
|
if uncompressed_size > CHUNK_SIZE {
|
||||||
bail!(
|
bail!(
|
||||||
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
||||||
@@ -469,8 +664,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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -625,6 +820,31 @@ 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();
|
||||||
@@ -657,8 +877,14 @@ mod tests {
|
|||||||
hex::encode(static_tag),
|
hex::encode(static_tag),
|
||||||
hex::encode(&static_buf)
|
hex::encode(&static_buf)
|
||||||
);
|
);
|
||||||
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
|
// Standardmäßig muss Legacy-AAD abgewiesen werden (S-07)
|
||||||
|
assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none());
|
||||||
|
|
||||||
|
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
|
||||||
|
set_allow_legacy_names(true);
|
||||||
|
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
|
||||||
assert_eq!(decrypted_static, filename);
|
assert_eq!(decrypted_static, filename);
|
||||||
|
set_allow_legacy_names(false);
|
||||||
|
|
||||||
// Mit anderem DEK schlägt Entschlüsselung fehl
|
// Mit anderem DEK schlägt Entschlüsselung fehl
|
||||||
let other_dek = generate_dek();
|
let other_dek = generate_dek();
|
||||||
@@ -677,7 +903,6 @@ mod tests {
|
|||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||||
|
|
||||||
// Gültige parent_ids entschlüsseln erfolgreich
|
// Gültige parent_ids entschlüsseln erfolgreich
|
||||||
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
||||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
||||||
@@ -689,8 +914,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_model_a_slot_payloads() {
|
fn test_model_a_slot_payloads() {
|
||||||
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
|
||||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
|
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
let dek_0 = generate_dek();
|
let dek_0 = generate_dek();
|
||||||
let dek_1 = generate_dek();
|
let dek_1 = generate_dek();
|
||||||
let carrier_node_id = 42i64;
|
let carrier_node_id = 42i64;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
pub mod carrier;
|
pub mod carrier;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
|
pub mod pathutil;
|
||||||
|
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;
|
||||||
|
|||||||
+451
-79
@@ -6,12 +6,14 @@ use tracing_subscriber::EnvFilter;
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
|
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
|
||||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
|
||||||
|
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
||||||
use sanctum::recovery::{
|
use sanctum::recovery::{
|
||||||
export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
|
detect_recovery_key_slot, export_header_backup, restore_header_backup,
|
||||||
|
restore_slot_from_recovery_key,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::ui;
|
use sanctum::ui;
|
||||||
@@ -23,6 +25,10 @@ use sanctum::verify::verify_container;
|
|||||||
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
/// Erlaubt Legacy-Namensentschlüsselung ohne deterministisches AAD (S-07)
|
||||||
|
#[arg(long, global = true, default_value_t = false)]
|
||||||
|
legacy_names: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
}
|
}
|
||||||
@@ -35,14 +41,12 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
||||||
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
|
/// Erstellt einen separaten, isolierten zweiten Tresor (Dual-Vault / Second Safe)
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
with_hidden: bool,
|
with_hidden: bool,
|
||||||
|
|
||||||
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
|
/// Dateiname der Alibi-Trägerdatei im Standard-Tresor (Standard: system_backup.dat).
|
||||||
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
|
/// Hinweis: Die Datei dient als Träger für den zweiten Tresor und ist forensisch erkennbar.
|
||||||
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
|
|
||||||
/// in forensischen Entropie-Scannern zu verhindern.
|
|
||||||
#[arg(long, default_value = "system_backup.dat")]
|
#[arg(long, default_value = "system_backup.dat")]
|
||||||
carrier_name: String,
|
carrier_name: String,
|
||||||
|
|
||||||
@@ -68,16 +72,22 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
||||||
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt standardmäßig automatisch das nächste freie Laufwerk)
|
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt unter Windows standardmäßig das nächste freie Laufwerk)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
drive: Option<String>,
|
drive: Option<String>,
|
||||||
|
|
||||||
|
/// Optionaler lokaler Mount-Pfad unter Linux/macOS (z. B. '/mnt/sanctum' oder '~/vault')
|
||||||
|
#[arg(short = 'm', long = "mount-point", value_name = "DIR")]
|
||||||
|
mount_point: Option<PathBuf>,
|
||||||
|
|
||||||
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
|
||||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
|
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage).
|
||||||
#[arg(long)]
|
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel sicher und interaktiv
|
||||||
|
/// einzugeben (verhindert Klartext-Einträge in der PowerShell-Historie).
|
||||||
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
|
|
||||||
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
|
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
|
||||||
@@ -99,6 +109,10 @@ enum Commands {
|
|||||||
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
|
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
no_anti_leak: bool,
|
no_anti_leak: bool,
|
||||||
|
|
||||||
|
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
|
||||||
|
#[arg(short, long, default_value_t = false)]
|
||||||
|
stealth: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
||||||
@@ -114,8 +128,9 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
||||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort)
|
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort).
|
||||||
#[arg(long)]
|
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
|
||||||
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -162,8 +177,9 @@ enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
header_file: Option<PathBuf>,
|
header_file: Option<PathBuf>,
|
||||||
|
|
||||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort.
|
||||||
#[arg(long)]
|
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
|
||||||
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
|
|
||||||
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
|
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
|
||||||
@@ -189,11 +205,77 @@ enum Commands {
|
|||||||
full: bool,
|
full: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Synchronisiert Dateien und Ordner zwischen Host und Container (rsync-artig, ohne Größenbeschränkung)
|
||||||
|
Sync {
|
||||||
|
/// Pfad zur .sanctum Containerdatei
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Quellpfad (lokales Verzeichnis/Datei oder Vault-Pfad bei --pull)
|
||||||
|
source: String,
|
||||||
|
|
||||||
|
/// Zielpfad (Vault-Pfad wie z. B. '/Downloads' oder lokaler Pfad bei --pull; Standard: '/')
|
||||||
|
#[arg(default_value = "/")]
|
||||||
|
target: String,
|
||||||
|
|
||||||
|
/// Kehrt die Richtung um: Lädt Daten aus dem Tresor auf die lokale Festplatte (Pull)
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
pull: bool,
|
||||||
|
|
||||||
|
/// Löscht Dateien im Ziel, die an der Quelle nicht mehr existieren (Spiegelung)
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
delete: bool,
|
||||||
|
|
||||||
|
/// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen
|
||||||
|
#[arg(short = 'n', long, default_value_t = false)]
|
||||||
|
dry_run: bool,
|
||||||
|
|
||||||
|
/// Vergleicht Dateien anhand kryptografischer Hashes statt nur Zeitstempel und Dateigröße
|
||||||
|
#[arg(short = 'c', long, default_value_t = false)]
|
||||||
|
checksum: bool,
|
||||||
|
|
||||||
|
/// Schließt Dateien oder Ordner anhand von Mustern aus (z. B. '*.tmp', '*.crdownload')
|
||||||
|
#[arg(long)]
|
||||||
|
exclude: Vec<String>,
|
||||||
|
|
||||||
|
/// Unterdrückt Pro-Datei-Ausgaben
|
||||||
|
#[arg(short, long, default_value_t = false)]
|
||||||
|
quiet: bool,
|
||||||
|
|
||||||
|
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
||||||
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
|
recovery_key: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Registriert .sanctum Containerdateien im Windows Explorer (Doppelklick & Kontextmenü, keine Adminrechte)
|
/// Registriert .sanctum Containerdateien im Windows Explorer (Doppelklick & Kontextmenü, keine Adminrechte)
|
||||||
Register,
|
Register,
|
||||||
|
|
||||||
/// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry
|
/// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry
|
||||||
Unregister,
|
Unregister,
|
||||||
|
|
||||||
|
/// Aktualisiert Sanctum auf die neueste Version (In-Place Self-Upgrade)
|
||||||
|
#[command(alias = "update")]
|
||||||
|
Upgrade {
|
||||||
|
/// Prüft nur auf verfügbare Aktualisierungen, ohne Dateien herunterzuladen
|
||||||
|
#[arg(short = 'c', long, default_value_t = false)]
|
||||||
|
check: bool,
|
||||||
|
|
||||||
|
/// Führt die Aktualisierung ohne interaktive Bestätigung durch
|
||||||
|
#[arg(short = 'y', long, default_value_t = false)]
|
||||||
|
yes: bool,
|
||||||
|
|
||||||
|
/// Erzwingt die Aktualisierung (auch bei gleicher Version oder erkannter Paketmanager-Verwaltung)
|
||||||
|
#[arg(short = 'f', long, default_value_t = false)]
|
||||||
|
force: bool,
|
||||||
|
|
||||||
|
/// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu)
|
||||||
|
#[arg(long, default_value = "https://gitea.pansi.eu")]
|
||||||
|
url: String,
|
||||||
|
|
||||||
|
/// Erlaubt abweichende Update-Server-URLs (erfordert interaktive Bestätigung)
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
insecure_url: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_drive_letter(s: &str) -> Result<char> {
|
fn parse_drive_letter(s: &str) -> Result<char> {
|
||||||
@@ -228,6 +310,61 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liest den 24-Wort BIP-39 Notfallschlüssel sicher ein.
|
||||||
|
/// Unterstützt:
|
||||||
|
/// 1. Interaktiv maskiert (wenn kein Wert oder leere Eingabe übergeben)
|
||||||
|
/// 2. Stdin (wenn "-" übergeben)
|
||||||
|
/// 3. Umgebungsvariable 'SANCTUM_RECOVERY_KEY' (wenn kein CLI-Argument übergeben)
|
||||||
|
/// 4. Veraltetes CLI-Argument mit Sicherheitswarnung (S-09)
|
||||||
|
fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>> {
|
||||||
|
if let Some(key) = arg {
|
||||||
|
if key == "-" {
|
||||||
|
println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan("ℹ"));
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
bail!("Der Notfallschlüssel von stdin darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
Ok(Some(Zeroizing::new(trimmed.to_string())))
|
||||||
|
} else if key.trim().is_empty() {
|
||||||
|
Ok(Some(prompt_recovery_key_interactive()?))
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{} WARNUNG: Die Angabe des Notfallschlüssels als CLI-Argument ist unsicher und veraltet!",
|
||||||
|
ui::yellow("[SICHERHEITSHINWEIS]")
|
||||||
|
);
|
||||||
|
eprintln!(" Verwenden Sie '--recovery-key' ohne Wert (interaktiv), '--recovery-key -' (stdin) oder die Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.");
|
||||||
|
Ok(Some(Zeroizing::new(key.to_string())))
|
||||||
|
}
|
||||||
|
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
|
||||||
|
// R-06: Umgebungsvariable nach dem Einlesen sofort aus dem Speicher des Prozesses entfernen
|
||||||
|
std::env::remove_var("SANCTUM_RECOVERY_KEY");
|
||||||
|
let trimmed = env_key.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ"));
|
||||||
|
Ok(Some(Zeroizing::new(trimmed.to_string())))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
|
||||||
|
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
|
||||||
|
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
|
||||||
|
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan("ℹ"));
|
||||||
|
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec"));
|
||||||
|
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
|
||||||
|
.context("Fehler beim Einlesen des Notfallschlüssels")?;
|
||||||
|
if phrase.trim().is_empty() {
|
||||||
|
bail!("Der Notfallschlüssel darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
Ok(Zeroizing::new(phrase))
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_init(
|
fn handle_init(
|
||||||
container_path: &Path,
|
container_path: &Path,
|
||||||
with_hidden: bool,
|
with_hidden: bool,
|
||||||
@@ -246,16 +383,13 @@ fn handle_init(
|
|||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!(" Zieldatei: {}", container_path.display());
|
println!(" Zieldatei: {}", container_path.display());
|
||||||
if with_hidden {
|
if with_hidden {
|
||||||
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
|
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei"));
|
||||||
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
||||||
let lower = carrier_name.to_lowercase();
|
|
||||||
if lower.ends_with(".iso") || lower.ends_with(".vhd") || lower.ends_with(".img") || lower.ends_with(".zip") {
|
|
||||||
println!(
|
println!(
|
||||||
" [{}] Hinweis zur Plausible Deniability: Dateitypen wie '.iso' oder '.zip' besitzen ein starres Format. Da Carrier-Blöcke verschlüsselt sind (Entropie ~8.0), empfehlen sich unformatierte Typen wie '.dat', '.bin' oder '.enc' für maximale forensische Unauffälligkeit.",
|
" [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.",
|
||||||
ui::yellow("Tipp")
|
ui::yellow("Info")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if with_hidden {
|
if with_hidden {
|
||||||
@@ -269,9 +403,8 @@ fn handle_init(
|
|||||||
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
if password_0.trim().is_empty() {
|
validate_password(&password_0)?;
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
let confirm_0 = Zeroizing::new(
|
let confirm_0 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
@@ -281,18 +414,15 @@ fn handle_init(
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
|
println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───");
|
||||||
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
|
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
|
||||||
let password_1 = Zeroizing::new(
|
let password_1 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
if password_1.trim().is_empty() {
|
validate_password(&password_1)?;
|
||||||
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
|
check_password_prefix_collision(&password_0, &password_1)?;
|
||||||
}
|
|
||||||
if *password_1 == *password_0 {
|
|
||||||
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
|
|
||||||
}
|
|
||||||
let confirm_1 = Zeroizing::new(
|
let confirm_1 = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
@@ -311,33 +441,36 @@ fn handle_init(
|
|||||||
let kdf_params_1 = KdfParams::default();
|
let kdf_params_1 = KdfParams::default();
|
||||||
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
|
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
|
||||||
|
|
||||||
ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG...");
|
ui::step(2, 4, "🎲", "Erzeuge unabhängige DEKs (DEK_0 & DEK_1)...");
|
||||||
let dek_0 = generate_dek();
|
let dek_0 = generate_dek();
|
||||||
let dek_1 = generate_dek();
|
let dek_1 = generate_dek();
|
||||||
|
|
||||||
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
|
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...");
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
let (wrapped_dek_0, header_nonce_0, header_tag_0) =
|
||||||
let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
||||||
|
|
||||||
ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
|
ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...");
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||||
|
|
||||||
|
let (wrapped_dek_1, header_nonce_1, header_tag_1) =
|
||||||
|
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
||||||
|
|
||||||
db.init_schema_with_carrier(
|
db.init_schema_with_carrier(
|
||||||
&salt_0,
|
&salt_0,
|
||||||
&kdf_params_0,
|
&kdf_params_0,
|
||||||
&wrapped_dek_0,
|
&wrapped_dek_0,
|
||||||
&nonce_0,
|
&header_nonce_0,
|
||||||
&tag_0,
|
&header_tag_0,
|
||||||
Some((
|
Some((
|
||||||
carrier_name,
|
carrier_name,
|
||||||
carrier_size_bytes,
|
carrier_size_bytes,
|
||||||
&salt_1,
|
&salt_1,
|
||||||
&kdf_params_1,
|
&kdf_params_1,
|
||||||
&wrapped_dek_1,
|
&wrapped_dek_1,
|
||||||
&nonce_1,
|
&header_nonce_1,
|
||||||
&tag_1,
|
&header_tag_1,
|
||||||
&dek_0,
|
&dek_0,
|
||||||
&dek_1,
|
&dek_1,
|
||||||
)),
|
)),
|
||||||
@@ -350,16 +483,16 @@ fn handle_init(
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! │");
|
println!("│ ✔ Sanctum Dual-Vault Container initialisiert! │");
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!();
|
println!();
|
||||||
println!(" • Container: {}", container_path.display());
|
println!(" • Container: {}", container_path.display());
|
||||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||||
println!(" • Alibi-Datei: {} ({})", carrier_name, carrier_size_str);
|
println!(" • Carrier-Datei:{} ({})", carrier_name, carrier_size_str);
|
||||||
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
|
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
|
||||||
println!(" • Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
|
println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
|
||||||
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
||||||
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
|
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
|
||||||
println!();
|
println!();
|
||||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||||
@@ -380,10 +513,7 @@ fn handle_init(
|
|||||||
rpassword::prompt_password("Master-Passwort eingeben: ")
|
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&password)?;
|
||||||
if password.trim().is_empty() {
|
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
||||||
@@ -430,7 +560,7 @@ fn handle_init(
|
|||||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||||
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
||||||
println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt");
|
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
|
||||||
println!();
|
println!();
|
||||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||||
@@ -505,11 +635,26 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
|
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(recovery_key)? {
|
||||||
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
|
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
|
||||||
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||||
(d, 0, None, None)
|
|
||||||
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0);
|
||||||
|
if detected_slot == 1 {
|
||||||
|
println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault).");
|
||||||
|
println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||||
|
let decoy_pw = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||||
|
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||||
|
);
|
||||||
|
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
|
let c_dek = decoy_auth.dek().clone();
|
||||||
|
(d, 1, Some(c_dek), db.find_carrier_node_id()?)
|
||||||
|
} else {
|
||||||
|
(d, 0, None, db.find_carrier_node_id()?)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let old_password = Zeroizing::new(
|
let old_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||||
@@ -540,10 +685,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
||||||
.context("Fehler beim Einlesen des neuen Passworts")?,
|
.context("Fehler beim Einlesen des neuen Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&new_password)?;
|
||||||
if new_password.trim().is_empty() {
|
|
||||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
@@ -562,7 +704,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
||||||
|
|
||||||
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
||||||
let c_dek = carrier_dek.unwrap_or_else(generate_dek);
|
let c_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
|
||||||
let c_nid = carrier_node_id.unwrap_or(0);
|
let c_nid = carrier_node_id.unwrap_or(0);
|
||||||
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
||||||
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||||
@@ -717,16 +859,48 @@ fn handle_restore_header(
|
|||||||
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
|
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
|
||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
||||||
} else if let Some(key) = recovery_key {
|
} else {
|
||||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
|
let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? {
|
||||||
|
key
|
||||||
|
} else {
|
||||||
|
// Interaktiver Auswahldialog statt Fehlermeldung
|
||||||
|
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
|
||||||
|
println!(" Bitte wählen Sie die gewünschte Wiederherstellungsmethode:");
|
||||||
|
println!(" [1] Sicherungsdatei verwenden (.sanctum.hdr)");
|
||||||
|
println!(" [2] 24-Wort BIP-39 Notfallschlüssel interaktiv eingeben");
|
||||||
|
print!(" Auswahl [1/2]: ");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
let mut choice = String::new();
|
||||||
|
std::io::stdin().read_line(&mut choice)?;
|
||||||
|
match choice.trim() {
|
||||||
|
"1" => {
|
||||||
|
print!(" Pfad zur .sanctum.hdr Sicherungsdatei: ");
|
||||||
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
let mut p = String::new();
|
||||||
|
std::io::stdin().read_line(&mut p)?;
|
||||||
|
let hdr_path = PathBuf::from(p.trim());
|
||||||
|
return handle_restore_header(container_path, Some(&hdr_path), None, slot);
|
||||||
|
}
|
||||||
|
"2" => prompt_recovery_key_interactive()?,
|
||||||
|
_ => bail!("Ungültige Auswahl. Wiederherstellung abgebrochen."),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
|
let effective_slot = if slot == 0 {
|
||||||
|
detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
slot
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot);
|
||||||
let new_password = Zeroizing::new(
|
let new_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
||||||
.context("Fehler beim Einlesen des Passworts")?,
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
);
|
);
|
||||||
|
validate_password(&new_password)?;
|
||||||
if new_password.trim().is_empty() {
|
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirm_password = Zeroizing::new(
|
let confirm_password = Zeroizing::new(
|
||||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
@@ -737,13 +911,26 @@ fn handle_restore_header(
|
|||||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dek_0_holder: Option<[u8; 32]> = if effective_slot == 1 {
|
||||||
|
println!(" Wiederherstellung von Slot 1 (Hidden Vault):");
|
||||||
|
println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||||
|
let decoy_pw = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||||
|
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||||
|
);
|
||||||
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
|
Some(**decoy_auth.dek())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let dek_0_opt = dek_0_holder.as_ref();
|
||||||
|
|
||||||
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
||||||
restore_slot_from_recovery_key(container_path, key, &new_password, slot)?;
|
restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?;
|
||||||
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
|
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot));
|
||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
||||||
} else {
|
|
||||||
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
|
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
@@ -774,12 +961,8 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
|||||||
.authenticate(&password)
|
.authenticate(&password)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||||
let dek = keys.dek().clone();
|
let dek = keys.dek().clone();
|
||||||
let slot_id = keys.slot_id();
|
|
||||||
|
|
||||||
let phrase = dek_to_mnemonic(&dek)?;
|
let phrase = dek_to_mnemonic(&dek)?;
|
||||||
if slot_id == 1 {
|
|
||||||
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
|
|
||||||
}
|
|
||||||
ui::print_recovery_phrase_card(&phrase);
|
ui::print_recovery_phrase_card(&phrase);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -807,9 +990,7 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
|||||||
|
|
||||||
match meta.authenticate(&password) {
|
match meta.authenticate(&password) {
|
||||||
Some(keys) => {
|
Some(keys) => {
|
||||||
let slot_id = keys.slot_id();
|
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"));
|
||||||
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
|
|
||||||
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc));
|
|
||||||
Some(keys.dek().clone())
|
Some(keys.dek().clone())
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -832,9 +1013,152 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_sync(
|
||||||
|
container_path: &Path,
|
||||||
|
source: &str,
|
||||||
|
target: &str,
|
||||||
|
pull: bool,
|
||||||
|
delete: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
checksum: bool,
|
||||||
|
exclude: Vec<String>,
|
||||||
|
quiet: bool,
|
||||||
|
recovery_key: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? {
|
||||||
|
ContainerAuth::RecoveryKey(phrase_str)
|
||||||
|
} else {
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Container: {}", container_path.display());
|
||||||
|
println!();
|
||||||
|
let pass = rpassword::prompt_password("Master-Passwort: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?;
|
||||||
|
ContainerAuth::Password(Zeroizing::new(pass))
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = Database::open(container_path)
|
||||||
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
|
||||||
|
let (dek, version, vault_id) = match auth {
|
||||||
|
ContainerAuth::Password(ref password) => {
|
||||||
|
let keys = meta
|
||||||
|
.authenticate(password)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||||
|
(keys.dek().clone(), meta.version, keys.slot_id())
|
||||||
|
}
|
||||||
|
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||||
|
let dek = mnemonic_to_dek(phrase)
|
||||||
|
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
|
let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0);
|
||||||
|
(dek, meta.version, v_id)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Richtung ermitteln
|
||||||
|
let direction = if pull || source.starts_with('/') {
|
||||||
|
sanctum::sync::SyncDirection::Pull
|
||||||
|
} else {
|
||||||
|
sanctum::sync::SyncDirection::Push
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = sanctum::sync::SyncOptions {
|
||||||
|
direction,
|
||||||
|
delete,
|
||||||
|
dry_run,
|
||||||
|
checksum,
|
||||||
|
exclude_patterns: exclude,
|
||||||
|
quiet,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !quiet {
|
||||||
|
let dir_str = match direction {
|
||||||
|
sanctum::sync::SyncDirection::Push => "Push (Host -> Container)",
|
||||||
|
sanctum::sync::SyncDirection::Pull => "Pull (Container -> Host)",
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
if dry_run {
|
||||||
|
println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]"));
|
||||||
|
}
|
||||||
|
println!(" • Richtung: {}", ui::cyan(dir_str));
|
||||||
|
println!(" • Quelle: {}", source);
|
||||||
|
println!(" • Ziel: {}", target);
|
||||||
|
if delete {
|
||||||
|
println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv"));
|
||||||
|
}
|
||||||
|
if checksum {
|
||||||
|
println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)");
|
||||||
|
}
|
||||||
|
if !options.exclude_patterns.is_empty() {
|
||||||
|
println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len());
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(" {} Starte Synchronisation ...", ui::dim("[-]"));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = sanctum::sync::run_sync(
|
||||||
|
&db,
|
||||||
|
vault_id,
|
||||||
|
&dek,
|
||||||
|
version,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
&options,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !dry_run {
|
||||||
|
let _ = db.checkpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !quiet {
|
||||||
|
println!();
|
||||||
|
if dry_run {
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ℹ Dry-Run Simulation erfolgreich abgeschlossen │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||||
|
println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||||
|
println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped);
|
||||||
|
if delete {
|
||||||
|
println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted);
|
||||||
|
}
|
||||||
|
println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow("✔"));
|
||||||
|
} else {
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ✔ Synchronisation erfolgreich abgeschlossen │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||||
|
println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||||
|
println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped);
|
||||||
|
if delete {
|
||||||
|
println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted);
|
||||||
|
}
|
||||||
|
let secs = stats.elapsed.as_secs_f64();
|
||||||
|
let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0);
|
||||||
|
let speed = if secs > 0.05 { mb / secs } else { 0.0 };
|
||||||
|
println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run() -> Result<()> {
|
async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.legacy_names {
|
||||||
|
set_allow_legacy_names(true);
|
||||||
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Init {
|
Commands::Init {
|
||||||
path,
|
path,
|
||||||
@@ -856,6 +1180,7 @@ async fn run() -> Result<()> {
|
|||||||
Commands::Mount {
|
Commands::Mount {
|
||||||
path,
|
path,
|
||||||
drive,
|
drive,
|
||||||
|
mount_point,
|
||||||
port,
|
port,
|
||||||
recovery_key,
|
recovery_key,
|
||||||
no_open,
|
no_open,
|
||||||
@@ -863,21 +1188,31 @@ async fn run() -> Result<()> {
|
|||||||
idle_timeout,
|
idle_timeout,
|
||||||
no_screen_lock,
|
no_screen_lock,
|
||||||
no_anti_leak,
|
no_anti_leak,
|
||||||
|
stealth,
|
||||||
} => {
|
} => {
|
||||||
let drive_char = match drive {
|
let drive_char = match drive {
|
||||||
Some(ref d) => parse_drive_letter(d)?,
|
Some(ref d) => parse_drive_letter(d)?,
|
||||||
None => {
|
None => {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
let auto_drive = sanctum::windows::find_next_available_drive()?;
|
let auto_drive = sanctum::windows::find_next_available_drive()?;
|
||||||
|
if !stealth {
|
||||||
println!(
|
println!(
|
||||||
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
|
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
|
||||||
ui::cyan("ℹ"),
|
ui::cyan("ℹ"),
|
||||||
auto_drive
|
auto_drive
|
||||||
);
|
);
|
||||||
|
}
|
||||||
auto_drive
|
auto_drive
|
||||||
}
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
'Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let auth = if let Some(key) = recovery_key {
|
let auth = if let Some(actual_key) = resolve_recovery_key(recovery_key.as_deref())? {
|
||||||
ContainerAuth::RecoveryKey(Zeroizing::new(key))
|
ContainerAuth::RecoveryKey(actual_key)
|
||||||
} else {
|
} else {
|
||||||
let prompt_text = format!(
|
let prompt_text = format!(
|
||||||
"Master-Passwort für Container '{}' eingeben: ",
|
"Master-Passwort für Container '{}' eingeben: ",
|
||||||
@@ -890,13 +1225,14 @@ async fn run() -> Result<()> {
|
|||||||
ContainerAuth::Password(password)
|
ContainerAuth::Password(password)
|
||||||
};
|
};
|
||||||
|
|
||||||
let open_explorer = !no_open;
|
let open_explorer = !no_open && !stealth;
|
||||||
let enable_tray = !no_tray;
|
let enable_tray = !no_tray && !stealth;
|
||||||
let lock_on_screen_lock = !no_screen_lock;
|
let lock_on_screen_lock = !no_screen_lock;
|
||||||
let anti_leak = !no_anti_leak;
|
let anti_leak = !no_anti_leak;
|
||||||
mount_container(
|
mount_container(
|
||||||
&path,
|
&path,
|
||||||
drive_char,
|
drive_char,
|
||||||
|
mount_point.as_deref(),
|
||||||
port,
|
port,
|
||||||
auth,
|
auth,
|
||||||
open_explorer,
|
open_explorer,
|
||||||
@@ -904,6 +1240,7 @@ async fn run() -> Result<()> {
|
|||||||
idle_timeout,
|
idle_timeout,
|
||||||
lock_on_screen_lock,
|
lock_on_screen_lock,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
stealth,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -936,6 +1273,31 @@ async fn run() -> Result<()> {
|
|||||||
Commands::Verify { path, full } => {
|
Commands::Verify { path, full } => {
|
||||||
handle_verify(&path, full)?;
|
handle_verify(&path, full)?;
|
||||||
}
|
}
|
||||||
|
Commands::Sync {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
pull,
|
||||||
|
delete,
|
||||||
|
dry_run,
|
||||||
|
checksum,
|
||||||
|
exclude,
|
||||||
|
quiet,
|
||||||
|
recovery_key,
|
||||||
|
} => {
|
||||||
|
handle_sync(
|
||||||
|
&path,
|
||||||
|
&source,
|
||||||
|
&target,
|
||||||
|
pull,
|
||||||
|
delete,
|
||||||
|
dry_run,
|
||||||
|
checksum,
|
||||||
|
exclude,
|
||||||
|
quiet,
|
||||||
|
recovery_key.as_deref(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Commands::Register => {
|
Commands::Register => {
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ Sanctum — Windows Explorer Integration einrichten │");
|
println!("│ Sanctum — Windows Explorer Integration einrichten │");
|
||||||
@@ -956,6 +1318,16 @@ async fn run() -> Result<()> {
|
|||||||
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
Commands::Upgrade { check, yes, force, url, insecure_url } => {
|
||||||
|
let options = sanctum::upgrade::UpgradeOptions {
|
||||||
|
check_only: check,
|
||||||
|
yes,
|
||||||
|
force,
|
||||||
|
base_url: url,
|
||||||
|
insecure_url,
|
||||||
|
};
|
||||||
|
sanctum::upgrade::run_upgrade(&options)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+450
-112
@@ -1,6 +1,7 @@
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -32,87 +33,52 @@ 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 WNetCancelConnection2W bzw. Unix-Mountpoint via gio.
|
||||||
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 output = Command::new("net")
|
crate::windows::unmount_drive_wnet(drive_letter)
|
||||||
.args(["use", &drive_str, "/delete", "/y"])
|
|
||||||
.output()
|
|
||||||
.context("Fehler beim 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);
|
|
||||||
bail!(
|
|
||||||
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
|
|
||||||
drive_str,
|
|
||||||
stdout,
|
|
||||||
stderr
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let _ = drive_letter;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
#[cfg(not(any(windows, unix)))]
|
||||||
|
{
|
||||||
|
let _ = drive_letter;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein.
|
/// Bindet ein Windows-Netzlaufwerk via in-process WNetAddConnection2W ein (R-05, kein Token in argv)
|
||||||
fn run_net_use_mount(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/`.
|
||||||
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
|
fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
let mut output = Command::new("net")
|
{
|
||||||
.args(["use", drive_str, &url, "/persistent:no"])
|
crate::windows::mount_drive_wnet(drive_letter, port, session_token)
|
||||||
.output()
|
}
|
||||||
.context("Fehler beim Ausführen des Befehls 'net use'")?;
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
|
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
||||||
if !output.status.success() {
|
let _ = Command::new("gio")
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
.args(["mount", &dav_url])
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let is_already_in_use = stderr.contains("85")
|
|
||||||
|| stderr.contains("bereits verwendet")
|
|
||||||
|| stderr.contains("already in use")
|
|
||||||
|| 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();
|
.output();
|
||||||
|
let _ = (drive_letter, session_token);
|
||||||
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
#[cfg(not(any(windows, unix)))]
|
||||||
|
{
|
||||||
|
let _ = (drive_letter, port, session_token);
|
||||||
|
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,
|
||||||
@@ -120,8 +86,14 @@ 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 _ = &drive_str;
|
||||||
|
let _ = mount_point;
|
||||||
|
let _ = open_explorer;
|
||||||
|
let _ = enable_tray;
|
||||||
|
let _ = lock_on_screen_lock;
|
||||||
|
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -130,8 +102,10 @@ pub async fn mount_container(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
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")?;
|
||||||
|
|
||||||
@@ -141,10 +115,14 @@ pub async fn mount_container(
|
|||||||
|
|
||||||
let (dek, carrier_dek, carrier_node_id, 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) => {
|
||||||
|
if !stealth {
|
||||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
||||||
|
}
|
||||||
match meta.authenticate(password) {
|
match meta.authenticate(password) {
|
||||||
Some(keys) => {
|
Some(keys) => {
|
||||||
|
if !stealth {
|
||||||
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
||||||
|
}
|
||||||
let vault_id = keys.slot_id();
|
let vault_id = keys.slot_id();
|
||||||
let ver = keys.version();
|
let ver = keys.version();
|
||||||
let c_dek = keys.carrier_dek();
|
let c_dek = keys.carrier_dek();
|
||||||
@@ -157,18 +135,36 @@ pub async fn mount_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContainerAuth::RecoveryKey(ref phrase) => {
|
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||||
|
if !stealth {
|
||||||
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
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")?;
|
||||||
|
if !stealth {
|
||||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||||
|
}
|
||||||
|
|
||||||
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
|
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
let is_hidden = {
|
let (carrier_dek, final_carrier_node_id, vault_id) = if carrier_node_id > 0 {
|
||||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
let slot_id = crate::recovery::detect_recovery_key_slot(&db, &dek, meta.version)?;
|
||||||
!children.is_empty()
|
if slot_id == 1 {
|
||||||
|
if !stealth {
|
||||||
|
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
|
||||||
|
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
|
||||||
|
}
|
||||||
|
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
|
||||||
|
let decoy_keys = meta.authenticate(&decoy_pass)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
|
||||||
|
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
|
||||||
|
} else {
|
||||||
|
(None, Some(carrier_node_id), 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None, 0)
|
||||||
};
|
};
|
||||||
let vault_id = if is_hidden { 1 } else { 0 };
|
|
||||||
(dek, None, None, meta.version, vault_id)
|
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,7 +172,6 @@ pub async fn mount_container(
|
|||||||
let mut token_bytes = [0u8; 16];
|
let mut token_bytes = [0u8; 16];
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||||
let session_token = hex::encode(token_bytes);
|
let session_token = hex::encode(token_bytes);
|
||||||
let token_path_prefix = format!("/{}", session_token);
|
|
||||||
|
|
||||||
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
||||||
let fs = SanctumFs::with_carrier(
|
let fs = SanctumFs::with_carrier(
|
||||||
@@ -190,7 +185,6 @@ pub async fn mount_container(
|
|||||||
);
|
);
|
||||||
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();
|
||||||
@@ -212,6 +206,8 @@ 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();
|
||||||
|
|
||||||
|
if !stealth {
|
||||||
|
#[cfg(windows)]
|
||||||
ui::step(
|
ui::step(
|
||||||
4,
|
4,
|
||||||
4,
|
4,
|
||||||
@@ -221,6 +217,17 @@ pub async fn mount_container(
|
|||||||
bound_port, drive_str
|
bound_port, drive_str
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
ui::step(
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"🌐",
|
||||||
|
&format!(
|
||||||
|
"Starte WebDAV-Server auf Port {} & initialisiere VFS...",
|
||||||
|
bound_port
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
@@ -228,24 +235,26 @@ pub async fn mount_container(
|
|||||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||||
listener,
|
listener,
|
||||||
dav_server,
|
dav_server,
|
||||||
token_path_prefix.clone(),
|
session_token.clone(),
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Netzlaufwerk einbinden
|
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||||
if let Err(e) = run_net_use_mount(&drive_str, bound_port, &session_token) {
|
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
|
||||||
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()
|
||||||
@@ -263,7 +272,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(());
|
||||||
});
|
});
|
||||||
@@ -302,8 +311,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),
|
||||||
@@ -316,61 +327,182 @@ pub async fn mount_container(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if stealth {
|
||||||
|
#[cfg(windows)]
|
||||||
|
println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
println!("Sanctum: WebDAV-Server auf Port {} bereit.", bound_port);
|
||||||
|
} else {
|
||||||
println!();
|
println!();
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
|
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!();
|
println!();
|
||||||
println!(" • Container: {}", container_path.display());
|
println!(" • Container: {}", container_path.display());
|
||||||
|
#[cfg(windows)]
|
||||||
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
|
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
|
||||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
|
#[cfg(not(windows))]
|
||||||
if vault_id == 1 {
|
{
|
||||||
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
|
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:{}/ (lokal geschützt)", bound_port);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
|
||||||
|
if let Some(mp) = mount_point {
|
||||||
|
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(secs) = idle_timeout {
|
if let Some(secs) = idle_timeout {
|
||||||
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
|
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
if lock_on_screen_lock {
|
if lock_on_screen_lock {
|
||||||
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
|
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
|
||||||
}
|
}
|
||||||
if anti_leak {
|
if anti_leak {
|
||||||
|
#[cfg(windows)]
|
||||||
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
|
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 {
|
if enable_tray {
|
||||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
#[cfg(windows)]
|
||||||
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
|
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!();
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
// Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
|
// 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);
|
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();
|
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)
|
// 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;
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
_ = console_close_rx.recv() => {
|
}
|
||||||
|
Some(()) = console_close_rx.recv() => {
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
_ = tray_shutdown_rx.recv() => {
|
}
|
||||||
|
Some(()) = tray_shutdown_rx.recv() => {
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
_ = session_lock_rx.recv() => {
|
}
|
||||||
|
Some(()) = session_lock_rx.recv() => {
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
_ = idle_shutdown_rx.recv() => {
|
}
|
||||||
|
Some(()) = idle_shutdown_rx.recv() => {
|
||||||
|
if !stealth {
|
||||||
println!();
|
println!();
|
||||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
tokio::select! {
|
||||||
|
res = tokio::signal::ctrl_c() => {
|
||||||
|
let _ = res;
|
||||||
|
if !stealth {
|
||||||
|
println!();
|
||||||
|
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("[!]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(windows, unix)))]
|
||||||
|
tokio::select! {
|
||||||
|
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);
|
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
@@ -380,6 +512,13 @@ pub async fn mount_container(
|
|||||||
} else {
|
} else {
|
||||||
println!("{}", ui::green("OK"));
|
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
|
// HTTP Server beenden
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
@@ -409,15 +548,64 @@ pub async fn mount_container(
|
|||||||
println!();
|
println!();
|
||||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
||||||
println!();
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
// S-10: Verifikation und Bereinigung von temporären SQLite WAL- und SHM-Dateien
|
||||||
|
let base_os = container_path.as_os_str().to_os_string();
|
||||||
|
let mut wal = base_os.clone();
|
||||||
|
wal.push("-wal");
|
||||||
|
let _ = std::fs::remove_file(wal);
|
||||||
|
let mut shm = base_os;
|
||||||
|
shm.push("-shm");
|
||||||
|
let _ = std::fs::remove_file(shm);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Überprüft, ob ein Host-Header auf Loopback (127.0.0.1, localhost, [::1]) zeigt (Anti-DNS-Rebinding).
|
/// Überprüft, ob ein Host-Header exakt auf Loopback (127.0.0.1, localhost, [::1], ::1) zeigt (Anti-DNS-Rebinding & S-06).
|
||||||
|
/// Striktes Parsing von Host und Port ohne anfällige starts_with-Präfixprüfungen.
|
||||||
pub fn is_loopback_host(host_str: &str) -> bool {
|
pub fn is_loopback_host(host_str: &str) -> bool {
|
||||||
let host_lower = host_str.to_ascii_lowercase();
|
let host_trimmed = host_str.trim().to_ascii_lowercase();
|
||||||
host_lower.starts_with("127.0.0.1")
|
if host_trimmed.is_empty() {
|
||||||
|| host_lower.starts_with("localhost")
|
return false;
|
||||||
|| host_lower.starts_with("[::1]")
|
}
|
||||||
|
|
||||||
|
if host_trimmed == "::1" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_part = if host_trimmed.starts_with('[') {
|
||||||
|
// IPv6 bracketed: [::1] oder [::1]:port
|
||||||
|
if let Some(bracket_end) = host_trimmed.find(']') {
|
||||||
|
let inside = &host_trimmed[1..bracket_end];
|
||||||
|
let after = &host_trimmed[bracket_end + 1..];
|
||||||
|
if !after.is_empty() {
|
||||||
|
if !after.starts_with(':') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if after[1..].parse::<u16>().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inside
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// IPv4 oder Hostname: localhost, localhost:8443, 127.0.0.1, 127.0.0.1:8443
|
||||||
|
if let Some((h, p)) = host_trimmed.rsplit_once(':') {
|
||||||
|
if p.parse::<u16>().is_ok() {
|
||||||
|
h
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
&host_trimmed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||||
@@ -426,14 +614,88 @@ const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
|||||||
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
|
/// 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);
|
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.
|
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
|
||||||
|
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
|
||||||
|
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
|
||||||
|
let auth_str = auth_header.trim();
|
||||||
|
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
|
||||||
|
rest
|
||||||
|
} else if let Some(rest) = auth_str.strip_prefix("basic ") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let decoded_bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.trim()) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded_str = match std::str::from_utf8(&decoded_bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
||||||
|
pass == expected_token
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
|
||||||
|
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
|
||||||
|
if let Some(val) = headers.get("X-Sanctum-Token") {
|
||||||
|
if let Ok(val_str) = val.to_str() {
|
||||||
|
return val_str.trim() == expected_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
|
||||||
|
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
|
||||||
|
let path = uri.path();
|
||||||
|
if !path.starts_with(prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &path[prefix.len()..];
|
||||||
|
let new_path = if rest.is_empty() || !rest.starts_with('/') {
|
||||||
|
format!("/{}", rest)
|
||||||
|
} else {
|
||||||
|
rest.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let path_and_query = match uri.query() {
|
||||||
|
Some(q) => format!("{}?{}", new_path, q),
|
||||||
|
None => new_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = uri.clone().into_parts();
|
||||||
|
parts.path_and_query = Some(path_and_query.parse().ok()?);
|
||||||
|
hyper::Uri::from_parts(parts).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
|
||||||
|
fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
|
||||||
|
hyper::Response::builder()
|
||||||
|
.status(hyper::StatusCode::UNAUTHORIZED)
|
||||||
|
.header(hyper::header::WWW_AUTHENTICATE, "Basic realm=\"Sanctum\"")
|
||||||
|
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||||
|
.body(dav_server::body::Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
|
||||||
pub async fn serve_webdav_loop(
|
pub async fn serve_webdav_loop(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
dav_server: DavHandler,
|
dav_server: DavHandler,
|
||||||
token_path_prefix: String,
|
session_token: String,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||||
|
let token_prefix = format!("/{}", session_token);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -461,13 +723,15 @@ pub async fn serve_webdav_loop(
|
|||||||
|
|
||||||
let io = TokioIo::new(stream);
|
let io = TokioIo::new(stream);
|
||||||
let handler = dav_server.clone();
|
let handler = dav_server.clone();
|
||||||
let expected_prefix = token_path_prefix.clone();
|
let expected_token = session_token.clone();
|
||||||
|
let expected_prefix = token_prefix.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||||
|
|
||||||
let service = service_fn(move |req| {
|
let service = service_fn(move |mut req| {
|
||||||
let h = handler.clone();
|
let h = handler.clone();
|
||||||
|
let token = expected_token.clone();
|
||||||
let prefix = expected_prefix.clone();
|
let prefix = expected_prefix.clone();
|
||||||
async move {
|
async move {
|
||||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||||
@@ -491,15 +755,36 @@ pub async fn serve_webdav_loop(
|
|||||||
return Ok::<_, Infallible>(res);
|
return Ok::<_, Infallible>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
|
// 2. R-05: Multi-Auth Middleware
|
||||||
let path = req.uri().path();
|
// a) HTTP Basic Auth (Authorization: Basic ...)
|
||||||
if !path.starts_with(&prefix) {
|
// b) Header X-Sanctum-Token
|
||||||
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
|
// c) Pfad-Präfix Fallback (/<session_token>/...)
|
||||||
let res = hyper::Response::builder()
|
let mut authenticated = false;
|
||||||
.status(hyper::StatusCode::FORBIDDEN)
|
|
||||||
.body(dav_server::body::Body::empty())
|
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||||
.unwrap();
|
if let Ok(auth_str) = auth_val.to_str() {
|
||||||
return Ok::<_, Infallible>(res);
|
if check_basic_auth(auth_str, &token) {
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated && check_token_header(req.headers(), &token) {
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||||
|
*req.uri_mut() = rewritten_uri;
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
} else if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||||
|
*req.uri_mut() = rewritten_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
||||||
|
return Ok::<_, Infallible>(unauthorized_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Infallible>(h.handle(req).await)
|
Ok::<_, Infallible>(h.handle(req).await)
|
||||||
@@ -512,7 +797,6 @@ pub async fn serve_webdav_loop(
|
|||||||
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
||||||
|
|
||||||
if let Err(err) = builder.serve_connection(io, service).await {
|
if let Err(err) = builder.serve_connection(io, service).await {
|
||||||
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
|
|
||||||
debug!("HTTP-Verbindungsende: {:?}", err);
|
debug!("HTTP-Verbindungsende: {:?}", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -551,6 +835,60 @@ mod tests {
|
|||||||
assert_eq!(format_drive('s'), "S:");
|
assert_eq!(format_drive('s'), "S:");
|
||||||
assert_eq!(format_drive('Z'), "Z:");
|
assert_eq!(format_drive('Z'), "Z:");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_basic_auth() {
|
||||||
|
use base64::Engine;
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let creds = format!("sanctum:{}", token);
|
||||||
|
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
|
||||||
|
assert!(check_basic_auth(&header_val, token));
|
||||||
|
|
||||||
|
// Kleingeschriebenes basic Präfix
|
||||||
|
let lower_header = format!("basic {}", base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token)));
|
||||||
|
assert!(check_basic_auth(&lower_header, token));
|
||||||
|
|
||||||
|
// Falsches Token
|
||||||
|
let wrong_token_header = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken"));
|
||||||
|
assert!(!check_basic_auth(&wrong_token_header, token));
|
||||||
|
|
||||||
|
// Ungültiges Base64 oder Format
|
||||||
|
assert!(!check_basic_auth("Basic !!!notbase64!!!", token));
|
||||||
|
assert!(!check_basic_auth("Bearer 12345", token));
|
||||||
|
assert!(!check_basic_auth("", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_header() {
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let mut headers = hyper::HeaderMap::new();
|
||||||
|
assert!(!check_token_header(&headers, token));
|
||||||
|
|
||||||
|
headers.insert("X-Sanctum-Token", token.parse().unwrap());
|
||||||
|
assert!(check_token_header(&headers, token));
|
||||||
|
|
||||||
|
let mut wrong_headers = hyper::HeaderMap::new();
|
||||||
|
wrong_headers.insert("X-Sanctum-Token", "wrong".parse().unwrap());
|
||||||
|
assert!(!check_token_header(&wrong_headers, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_path_prefix() {
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let prefix = format!("/{}", token);
|
||||||
|
|
||||||
|
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg?sort=date", token).parse().unwrap();
|
||||||
|
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
|
||||||
|
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
|
||||||
|
assert_eq!(stripped.query(), Some("sort=date"));
|
||||||
|
|
||||||
|
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
|
||||||
|
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
|
||||||
|
assert_eq!(stripped_root.path(), "/");
|
||||||
|
|
||||||
|
let uri_no_prefix: hyper::Uri = "http://127.0.0.1:8443/other/path".parse().unwrap();
|
||||||
|
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
||||||
|
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_node_name() {
|
||||||
|
assert!(validate_node_name("test.txt").is_ok());
|
||||||
|
assert!(validate_node_name("").is_err());
|
||||||
|
assert!(validate_node_name(".").is_err());
|
||||||
|
assert!(validate_node_name("..").is_err());
|
||||||
|
assert!(validate_node_name("foo/bar").is_err());
|
||||||
|
assert!(validate_node_name("foo\\bar").is_err());
|
||||||
|
assert!(validate_node_name("CON").is_err());
|
||||||
|
assert!(validate_node_name("aux.txt").is_err());
|
||||||
|
assert!(validate_node_name("nul").is_err());
|
||||||
|
assert!(validate_node_name("com1.log").is_err());
|
||||||
|
assert!(validate_node_name("line\nbreak").is_err());
|
||||||
|
assert!(validate_node_name("null\0byte").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
+39
-14
@@ -6,8 +6,8 @@ use anyhow::{bail, Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
|
decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
|
||||||
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
||||||
|
|
||||||
@@ -222,6 +222,30 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ermittelt automatisch, ob ein Notfallschlüssel zu Slot 0 (Standard/Decoy) oder Slot 1 (Hidden Vault) gehört (S-05).
|
||||||
|
pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> Result<u32> {
|
||||||
|
if let Ok(Some(carrier_id)) = db.find_carrier_node_id() {
|
||||||
|
if let Ok(Some(chunk0)) = db.read_chunk(carrier_id, 0) {
|
||||||
|
let is_dek0 = decrypt_chunk(
|
||||||
|
dek,
|
||||||
|
carrier_id,
|
||||||
|
0,
|
||||||
|
&chunk0.ciphertext,
|
||||||
|
&chunk0.nonce,
|
||||||
|
&chunk0.tag,
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
if is_dek0 {
|
||||||
|
return Ok(0);
|
||||||
|
} else {
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
|
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
|
||||||
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
||||||
pub fn restore_slot_from_recovery_key(
|
pub fn restore_slot_from_recovery_key(
|
||||||
@@ -229,14 +253,13 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
recovery_key: &str,
|
recovery_key: &str,
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
target_slot_id: u32,
|
target_slot_id: u32,
|
||||||
|
dek_0: Option<&[u8; 32]>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_password.trim().is_empty() {
|
validate_password(new_password)?;
|
||||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
||||||
let dek = mnemonic_to_dek(recovery_key)
|
let dek = mnemonic_to_dek(recovery_key)
|
||||||
@@ -258,9 +281,11 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
let mut existing_slots = db.read_slots().unwrap_or_default();
|
let mut existing_slots = db.read_slots().unwrap_or_default();
|
||||||
|
|
||||||
let new_slot = if target_slot_id == 1 {
|
let new_slot = if target_slot_id == 1 {
|
||||||
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
|
// Für Slot 1 wird der echte DEK_0 benötigt (S-05)
|
||||||
let dek_0_bytes = [0u8; 32];
|
let dek_0_bytes = dek_0.ok_or_else(|| {
|
||||||
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
|
anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
|
||||||
|
})?;
|
||||||
|
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
|
||||||
SlotMeta {
|
SlotMeta {
|
||||||
slot_id: 1,
|
slot_id: 1,
|
||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
@@ -289,7 +314,7 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
existing_slots.push(new_slot);
|
existing_slots.push(new_slot);
|
||||||
existing_slots.sort_by_key(|s| s.slot_id);
|
existing_slots.sort_by_key(|s| s.slot_id);
|
||||||
|
|
||||||
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
|
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Dummy-Slot-Längenparität
|
||||||
if !existing_slots.iter().any(|s| s.slot_id == 1) {
|
if !existing_slots.iter().any(|s| s.slot_id == 1) {
|
||||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
let dummy_slot = SlotMeta {
|
let dummy_slot = SlotMeta {
|
||||||
@@ -333,7 +358,7 @@ pub fn restore_header_from_recovery_key(
|
|||||||
recovery_key: &str,
|
recovery_key: &str,
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
|
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -358,8 +383,8 @@ mod tests {
|
|||||||
let password = "SuperSecretPassword2026!";
|
let password = "SuperSecretPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -413,8 +438,8 @@ mod tests {
|
|||||||
|
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
+322
-20
@@ -9,7 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
|||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
||||||
KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
|
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
|
||||||
|
MAGIC_BYTES,
|
||||||
};
|
};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
@@ -158,12 +159,98 @@ fn current_timestamp() -> u64 {
|
|||||||
impl Database {
|
impl Database {
|
||||||
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
|
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
|
||||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
let conn = Connection::open(path)?;
|
let path_ref = path.as_ref();
|
||||||
|
let conn = match Connection::open(path_ref) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("Access is denied")
|
||||||
|
|| err_str.contains("permission denied")
|
||||||
|
|| err_str.contains("os error 5")
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"Zugriff auf '{}' verweigert (OS Fehler 5 / Access Denied).\n\
|
||||||
|
[!] Möglicherweise blockiert durch Windows Defender 'Überwachter Ordnerzugriff' (Controlled Folder Access).\n\
|
||||||
|
[i] Abhilfe: Fügen Sie 'sanctum.exe' in den Windows-Sicherheitseinstellungen (Viren- & Bedrohungsschutz -> Ransomware-Schutz -> Überwachter Ordnerzugriff) als erlaubte App hinzu, oder platzieren Sie den Container außerhalb geschützter Benutzerordner.",
|
||||||
|
path_ref.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// S-04: Untrusted Container & Fremddatei-Schutz.
|
||||||
|
// Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden:
|
||||||
|
// Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen.
|
||||||
|
let table_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if table_count > 0 {
|
||||||
|
let has_sanctum_schema: bool = {
|
||||||
|
let has_meta: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !has_meta {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
let magic_opt: Option<Option<Vec<u8>>> = conn
|
||||||
|
.query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0))
|
||||||
|
.optional()?;
|
||||||
|
match magic_opt {
|
||||||
|
Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(),
|
||||||
|
Some(None) => false,
|
||||||
|
None => {
|
||||||
|
// Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung).
|
||||||
|
// Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist,
|
||||||
|
// um Fremddatenbanken weiterhin strikt abzuweisen.
|
||||||
|
let has_nodes: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
let has_chunks: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
has_nodes && has_chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_sanctum_schema {
|
||||||
|
bail!(
|
||||||
|
"Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.",
|
||||||
|
path_ref.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
};
|
};
|
||||||
db.init_pragmas()?;
|
db.init_pragmas()?;
|
||||||
|
if table_count > 0 {
|
||||||
db.ensure_schema_upgrades()?;
|
db.ensure_schema_upgrades()?;
|
||||||
|
}
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,10 +273,16 @@ impl Database {
|
|||||||
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
||||||
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
||||||
let meta = self.read_meta()?;
|
let meta = self.read_meta()?;
|
||||||
Ok(meta.authenticate(password))
|
let res = meta.authenticate(password);
|
||||||
|
if let Some(ref keys) = res {
|
||||||
|
if let Some(cid) = keys.carrier_node_id() {
|
||||||
|
let _ = self.mark_carrier_node_id(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
|
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch.
|
||||||
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
||||||
@@ -201,6 +294,9 @@ impl Database {
|
|||||||
// Spalte slot_id in meta (falls aus v1 migriert)
|
// Spalte slot_id in meta (falls aus v1 migriert)
|
||||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
|
||||||
|
// Spalte is_carrier in nodes (S-03 Carrier-Schutz)
|
||||||
|
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,16 +349,41 @@ impl Database {
|
|||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
|
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
|
||||||
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
"SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1",
|
||||||
)?;
|
)?;
|
||||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markiert einen existierenden Knoten explizit als Carrier.
|
||||||
|
pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
|
||||||
|
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
|
||||||
|
if node_id == ancestor_id {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"WITH RECURSIVE sub(id) AS (
|
||||||
|
SELECT id FROM nodes WHERE id = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
||||||
|
)
|
||||||
|
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
|
||||||
|
)?;
|
||||||
|
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
|
||||||
|
Ok(exists.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
||||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -338,6 +459,7 @@ impl Database {
|
|||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
modified_at INTEGER NOT NULL,
|
modified_at INTEGER NOT NULL,
|
||||||
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||||
@@ -394,10 +516,10 @@ impl Database {
|
|||||||
dek_1,
|
dek_1,
|
||||||
)) = carrier_config
|
)) = carrier_config
|
||||||
{
|
{
|
||||||
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
|
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen (is_carrier = 1)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
||||||
VALUES (1, ?1, 0, ?2, ?3, ?4)",
|
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
|
||||||
params![c_name, c_size as i64, now, now],
|
params![c_name, c_size as i64, now, now],
|
||||||
)?;
|
)?;
|
||||||
let c_id = conn.last_insert_rowid();
|
let c_id = conn.last_insert_rowid();
|
||||||
@@ -517,7 +639,7 @@ impl Database {
|
|||||||
Ok(carrier_node_id)
|
Ok(carrier_node_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
|
/// Initialisiert das Datenbankschema mit Unterstützung für Dual-Vault (optionaler Hidden Vault).
|
||||||
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
|
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
|
||||||
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
|
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
|
||||||
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
|
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
|
||||||
@@ -553,6 +675,7 @@ impl Database {
|
|||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
modified_at INTEGER NOT NULL,
|
modified_at INTEGER NOT NULL,
|
||||||
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||||
@@ -597,7 +720,7 @@ impl Database {
|
|||||||
params![now, now],
|
params![now, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Slot 1: Entweder echter Hidden Vault ODER ununterscheidbares kryptografisches Rauschen (Plausible Deniability)
|
// Slot 1: Entweder echter Hidden Vault ODER CSPRNG-Rauschen (Dummy-Slot-Längenparität)
|
||||||
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
|
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
|
||||||
let params_json_1 = serde_json::to_string(h_params)?;
|
let params_json_1 = serde_json::to_string(h_params)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -635,7 +758,7 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability).
|
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot-Längenparität).
|
||||||
pub fn init_schema(
|
pub fn init_schema(
|
||||||
&self,
|
&self,
|
||||||
salt: &[u8; 16],
|
salt: &[u8; 16],
|
||||||
@@ -665,19 +788,88 @@ impl Database {
|
|||||||
let tag_vec: Vec<u8> = row.get(6)?;
|
let tag_vec: Vec<u8> = row.get(6)?;
|
||||||
|
|
||||||
let mut kdf_salt = [0u8; 16];
|
let mut kdf_salt = [0u8; 16];
|
||||||
if salt_vec.len() == 16 {
|
if salt_vec.len() != 16 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
2,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
kdf_salt.copy_from_slice(&salt_vec);
|
kdf_salt.copy_from_slice(&salt_vec);
|
||||||
}
|
|
||||||
let mut header_nonce = [0u8; 12];
|
let mut header_nonce = [0u8; 12];
|
||||||
if nonce_vec.len() == 12 {
|
if nonce_vec.len() != 12 {
|
||||||
header_nonce.copy_from_slice(&nonce_vec);
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
5,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
header_nonce.copy_from_slice(&nonce_vec);
|
||||||
|
|
||||||
let mut header_tag = [0u8; 16];
|
let mut header_tag = [0u8; 16];
|
||||||
if tag_vec.len() == 16 {
|
if tag_vec.len() != 16 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
6,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
header_tag.copy_from_slice(&tag_vec);
|
header_tag.copy_from_slice(&tag_vec);
|
||||||
|
|
||||||
|
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
|
||||||
|
if slot_id == 0 {
|
||||||
|
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
4,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if slot_id == 1 {
|
||||||
|
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
|
||||||
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
4,
|
||||||
|
rusqlite::types::Type::Blob,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default();
|
let kdf_params: KdfParams = serde_json::from_str(¶ms_str)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
3,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id),
|
||||||
|
)),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
validate_kdf_params(&kdf_params)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
3,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id),
|
||||||
|
)),
|
||||||
|
))?;
|
||||||
|
|
||||||
Ok(SlotMeta {
|
Ok(SlotMeta {
|
||||||
slot_id,
|
slot_id,
|
||||||
@@ -999,6 +1191,7 @@ impl Database {
|
|||||||
is_dir: bool,
|
is_dir: bool,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
) -> Result<NodeRecord> {
|
) -> Result<NodeRecord> {
|
||||||
|
crate::pathutil::validate_node_name(name)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
@@ -1047,8 +1240,32 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen
|
||||||
|
/// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig
|
||||||
|
/// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03).
|
||||||
|
pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> {
|
||||||
|
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||||
|
if node_id == carrier_id {
|
||||||
|
bail!(
|
||||||
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).",
|
||||||
|
node_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
||||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
pub fn delete_node(&self, id: i64) -> Result<()> {
|
||||||
|
self.assert_not_carrier(id)?;
|
||||||
|
|
||||||
|
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
||||||
|
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||||
|
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
||||||
|
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
||||||
let _ = self.shred_chunks_for_node(id);
|
let _ = self.shred_chunks_for_node(id);
|
||||||
|
|
||||||
@@ -1082,6 +1299,8 @@ impl Database {
|
|||||||
vault_id: u32,
|
vault_id: u32,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
self.assert_not_carrier(id)?;
|
||||||
|
crate::pathutil::validate_node_name(new_name)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let stored_name = if vault_id == 1 {
|
let stored_name = if vault_id == 1 {
|
||||||
@@ -1164,9 +1383,48 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar
|
||||||
|
/// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01).
|
||||||
|
pub fn write_chunk_and_update_size(
|
||||||
|
&self,
|
||||||
|
node_id: i64,
|
||||||
|
chunk_index: u32,
|
||||||
|
nonce: &[u8; 12],
|
||||||
|
tag: &[u8; 16],
|
||||||
|
ciphertext: &[u8],
|
||||||
|
new_size: u64,
|
||||||
|
modified_at: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.assert_not_carrier(node_id)?;
|
||||||
|
let mut conn = self.conn.lock().unwrap();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
|
||||||
|
nonce = excluded.nonce,
|
||||||
|
tag = excluded.tag,
|
||||||
|
ciphertext = excluded.ciphertext",
|
||||||
|
params![
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
nonce.as_slice(),
|
||||||
|
tag.as_slice(),
|
||||||
|
ciphertext,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
|
||||||
|
params![new_size as i64, modified_at as i64, node_id],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
||||||
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
||||||
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
|
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
|
||||||
|
self.assert_not_carrier(node_id)?;
|
||||||
let mut conn = self.conn.lock().unwrap();
|
let mut conn = self.conn.lock().unwrap();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
{
|
{
|
||||||
@@ -1334,7 +1592,7 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
|
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Längenparität
|
||||||
if !has_slot1 {
|
if !has_slot1 {
|
||||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
||||||
@@ -1654,7 +1912,7 @@ mod tests {
|
|||||||
params![hidden_file.id],
|
params![hidden_file.id],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
// Plausible Deniability: Kein $h$-Präfix, kein Klartext
|
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
|
||||||
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
||||||
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
||||||
|
|
||||||
@@ -1673,4 +1931,48 @@ mod tests {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_atomic_chunk_write_and_size_update() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
||||||
|
[1u8; 16],
|
||||||
|
KdfParams::default(),
|
||||||
|
vec![2u8; 40],
|
||||||
|
[3u8; 12],
|
||||||
|
[4u8; 16],
|
||||||
|
);
|
||||||
|
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
|
||||||
|
let file = db.create_node(1, "crash_test.bin", false).unwrap();
|
||||||
|
assert_eq!(file.size, 0);
|
||||||
|
|
||||||
|
let chunk_nonce = [5u8; 12];
|
||||||
|
let chunk_tag = [6u8; 16];
|
||||||
|
let ciphertext = vec![7u8; 1024];
|
||||||
|
let new_size = 1024u64;
|
||||||
|
let modified_at = 2000000u64;
|
||||||
|
|
||||||
|
db.write_chunk_and_update_size(
|
||||||
|
file.id,
|
||||||
|
0,
|
||||||
|
&chunk_nonce,
|
||||||
|
&chunk_tag,
|
||||||
|
&ciphertext,
|
||||||
|
new_size,
|
||||||
|
modified_at,
|
||||||
|
).expect("Atomic write");
|
||||||
|
|
||||||
|
// Chunk verifizieren
|
||||||
|
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
|
||||||
|
assert_eq!(chunk.ciphertext, ciphertext);
|
||||||
|
assert_eq!(chunk.nonce, chunk_nonce);
|
||||||
|
assert_eq!(chunk.tag, chunk_tag);
|
||||||
|
|
||||||
|
// Inode verifizieren
|
||||||
|
let nodes = db.list_children(1).unwrap();
|
||||||
|
let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap();
|
||||||
|
assert_eq!(updated_file.size, new_size);
|
||||||
|
assert_eq!(updated_file.modified_at, modified_at);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+960
@@ -0,0 +1,960 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
pub use crate::pathutil::validate_node_name;
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
db.assert_not_carrier(node.id)?;
|
||||||
|
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> {
|
||||||
|
db.assert_not_carrier(node.id)?;
|
||||||
|
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 carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
|
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
|
||||||
|
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
+5
-5
@@ -310,8 +310,8 @@ mod tests {
|
|||||||
let password = "HealthyTestPassword123!";
|
let password = "HealthyTestPassword123!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -341,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, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
|
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + 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);
|
||||||
@@ -362,8 +362,8 @@ mod tests {
|
|||||||
let password = "BitrotTestPassword123!";
|
let password = "BitrotTestPassword123!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: crate::crypto::MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
|
|||||||
+223
-32
@@ -168,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| {
|
||||||
@@ -177,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(())
|
||||||
}
|
}
|
||||||
@@ -199,7 +225,7 @@ 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 {
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||||
data.zeroize();
|
data.zeroize();
|
||||||
}
|
}
|
||||||
@@ -236,17 +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);
|
||||||
}
|
}
|
||||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||||
data.zeroize();
|
data.zeroize();
|
||||||
}
|
}
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,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..];
|
||||||
@@ -370,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(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -446,6 +455,16 @@ impl SanctumFs {
|
|||||||
let dek_arc = Arc::new(dek);
|
let dek_arc = Arc::new(dek);
|
||||||
let carrier_dek_arc = carrier_dek.map(Arc::new);
|
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 {
|
let carrier_fs = if vault_id == 1 {
|
||||||
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
|
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
|
||||||
match CarrierFs::load(
|
match CarrierFs::load(
|
||||||
@@ -469,6 +488,12 @@ impl SanctumFs {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Forensischer RAM-Paging-Schutz via VirtualLock (verhindert Auslagerung in pagefile.sys)
|
||||||
|
crate::windows::lock_memory(dek_arc.as_ptr(), 32);
|
||||||
|
if let Some(ref c_dek) = carrier_dek_arc {
|
||||||
|
crate::windows::lock_memory(c_dek.as_ptr(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
dek: dek_arc,
|
dek: dek_arc,
|
||||||
@@ -523,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)
|
||||||
@@ -535,6 +561,8 @@ 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)?;
|
||||||
|
crate::pathutil::validate_node_name(name).map_err(|_| FsError::Forbidden)?;
|
||||||
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| {
|
||||||
@@ -544,12 +572,36 @@ 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)?;
|
||||||
|
crate::pathutil::validate_node_name(new_name).map_err(|_| FsError::Forbidden)?;
|
||||||
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,
|
||||||
@@ -772,6 +824,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)?;
|
||||||
@@ -851,6 +910,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)?;
|
||||||
@@ -880,12 +943,24 @@ impl DavFileSystem for SanctumFs {
|
|||||||
return Err(FsError::NotImplemented);
|
return Err(FsError::NotImplemented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-02: Schutz der Trägerdatei: Kopieren der Trägerdatei (Quelle) ist strikt 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) {
|
||||||
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)?;
|
||||||
@@ -984,11 +1059,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)
|
||||||
@@ -1109,4 +1184,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+312
-18
@@ -1,6 +1,9 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
/// 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').
|
||||||
pub fn find_next_available_drive() -> Result<char> {
|
pub fn find_next_available_drive() -> Result<char> {
|
||||||
@@ -32,8 +35,11 @@ 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<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
|
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
|
||||||
Command::new("explorer.exe")
|
Command::new("explorer.exe")
|
||||||
.arg(&drive_path)
|
.arg(&drive_path)
|
||||||
@@ -41,6 +47,57 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
|||||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
|
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
|
||||||
Ok(())
|
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).
|
||||||
pub fn notify_shell_associations_changed() {
|
pub fn notify_shell_associations_changed() {
|
||||||
@@ -67,10 +124,19 @@ 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<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
let current_exe = std::env::current_exe()
|
let current_exe = std::env::current_exe()
|
||||||
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
|
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
|
||||||
let exe_str = current_exe.display().to_string();
|
let exe_str = current_exe.display().to_string();
|
||||||
@@ -98,14 +164,14 @@ pub fn register_explorer_integration() -> Result<()> {
|
|||||||
(
|
(
|
||||||
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",
|
||||||
"",
|
"",
|
||||||
@@ -114,18 +180,18 @@ pub fn register_explorer_integration() -> Result<()> {
|
|||||||
(
|
(
|
||||||
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\"\""),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -149,9 +215,31 @@ pub fn register_explorer_integration() -> Result<()> {
|
|||||||
notify_shell_associations_changed();
|
notify_shell_associations_changed();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
#[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);
|
||||||
|
|
||||||
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU).
|
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";
|
||||||
|
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) bzw. unter Linux aus Freedesktop.
|
||||||
pub fn unregister_explorer_integration() -> Result<()> {
|
pub fn unregister_explorer_integration() -> Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
let keys_to_delete = [
|
let keys_to_delete = [
|
||||||
r"HKCU\Software\Classes\.sanctum",
|
r"HKCU\Software\Classes\.sanctum",
|
||||||
r"HKCU\Software\Classes\Sanctum.Container",
|
r"HKCU\Software\Classes\Sanctum.Container",
|
||||||
@@ -166,6 +254,17 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
|||||||
notify_shell_associations_changed();
|
notify_shell_associations_changed();
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -198,10 +297,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<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +526,37 @@ static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[repr(C)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct NETRESOURCEW {
|
||||||
|
dwScope: u32,
|
||||||
|
dwType: u32,
|
||||||
|
dwDisplayType: u32,
|
||||||
|
dwUsage: u32,
|
||||||
|
lpLocalName: *mut u16,
|
||||||
|
lpRemoteName: *mut u16,
|
||||||
|
lpComment: *mut u16,
|
||||||
|
lpProvider: *mut u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[link(name = "mpr")]
|
||||||
|
extern "system" {
|
||||||
|
fn WNetAddConnection2W(
|
||||||
|
lpNetResource: *const NETRESOURCEW,
|
||||||
|
lpPassword: *const u16,
|
||||||
|
lpUserName: *const u16,
|
||||||
|
dwFlags: u32,
|
||||||
|
) -> u32;
|
||||||
|
|
||||||
|
fn WNetCancelConnection2W(
|
||||||
|
lpName: *const u16,
|
||||||
|
dwFlags: u32,
|
||||||
|
fForce: i32,
|
||||||
|
) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||||
const CTRL_C_EVENT: u32 = 0;
|
const CTRL_C_EVENT: u32 = 0;
|
||||||
@@ -438,13 +567,17 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
|||||||
|
|
||||||
match ctrl_type {
|
match ctrl_type {
|
||||||
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
|
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
|
||||||
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
|
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen (in-process)
|
||||||
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||||
if let Some(dl) = *guard {
|
if let Some(dl) = *guard {
|
||||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||||
let _ = std::process::Command::new("net")
|
let wide_drive: Vec<u16> = drive_str
|
||||||
.args(["use", &drive_str, "/delete", "/y"])
|
.encode_utf16()
|
||||||
.output();
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
unsafe {
|
||||||
|
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
|
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
|
||||||
@@ -527,10 +660,171 @@ pub fn start_console_ctrl_monitor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bindet ein Windows-Netzlaufwerk über WNetAddConnection2W ein (in-process, ohne argv-Token-Leak).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
|
||||||
|
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||||
|
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||||
|
let mut remote_name: Vec<u16> = remote_url.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let password: Vec<u16> = session_token.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
|
||||||
|
let nr = NETRESOURCEW {
|
||||||
|
dwScope: 0,
|
||||||
|
dwType: 1, // RESOURCETYPE_DISK
|
||||||
|
dwDisplayType: 0,
|
||||||
|
dwUsage: 0,
|
||||||
|
lpLocalName: local_name.as_mut_ptr(),
|
||||||
|
lpRemoteName: remote_name.as_mut_ptr(),
|
||||||
|
lpComment: std::ptr::null_mut(),
|
||||||
|
lpProvider: std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut res = unsafe {
|
||||||
|
WNetAddConnection2W(
|
||||||
|
&nr,
|
||||||
|
password.as_ptr(),
|
||||||
|
username.as_ptr(),
|
||||||
|
0, // Nicht persistent
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
|
||||||
|
if res == 85 || res == 1202 {
|
||||||
|
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
|
||||||
|
unsafe {
|
||||||
|
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
|
||||||
|
}
|
||||||
|
res = unsafe {
|
||||||
|
WNetAddConnection2W(
|
||||||
|
&nr,
|
||||||
|
password.as_ptr(),
|
||||||
|
username.as_ptr(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if res != 0 {
|
||||||
|
let webclient_hint = if res == 67 {
|
||||||
|
"\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 (Win32 Fehlercode {}){}",
|
||||||
|
drive_str,
|
||||||
|
res,
|
||||||
|
webclient_hint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W (in-process).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> {
|
||||||
|
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||||
|
let local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
|
||||||
|
let res = unsafe {
|
||||||
|
WNetCancelConnection2W(local_name.as_ptr(), 0, 1 /* fForce = TRUE */)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0 = NO_ERROR, 2250 = ERROR_NOT_CONNECTED (bereits getrennt)
|
||||||
|
if res != 0 && res != 2250 {
|
||||||
|
bail!(
|
||||||
|
"Netzlaufwerk {} konnte nicht getrennt werden (Win32 Fehlercode {})",
|
||||||
|
drive_str,
|
||||||
|
res
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn mount_drive_wnet(_drive_letter: char, _port: u16, _session_token: &str) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[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");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use rand::RngCore;
|
|||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
||||||
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
|
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::verify::verify_container;
|
use sanctum::verify::verify_container;
|
||||||
@@ -31,8 +31,8 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
|||||||
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,8 +270,8 @@ async fn test_model_a_container_file_size_invariance() {
|
|||||||
let pass_hidden = "HiddenPass2026!";
|
let pass_hidden = "HiddenPass2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,8 +369,8 @@ async fn test_carrier_file_drop_and_append_mode() {
|
|||||||
let pass_hidden = "HiddenSecretPassword2026!";
|
let pass_hidden = "HiddenSecretPassword2026!";
|
||||||
|
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+48
-27
@@ -11,6 +11,7 @@ use sanctum::{
|
|||||||
crypto::{
|
crypto::{
|
||||||
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
|
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
|
||||||
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
||||||
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
},
|
},
|
||||||
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
||||||
storage::Database,
|
storage::Database,
|
||||||
@@ -34,8 +35,8 @@ async fn test_sanctum_full_container_lifecycle() {
|
|||||||
// 1. Initialisierung
|
// 1. Initialisierung
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024, // Schnell für Tests
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||||
@@ -176,8 +177,8 @@ async fn test_sanctum_password_change() {
|
|||||||
// 1. Initialisierung mit Passwort v1
|
// 1. Initialisierung mit Passwort v1
|
||||||
let salt_v1 = generate_salt();
|
let salt_v1 = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
|
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
|
||||||
@@ -262,8 +263,8 @@ async fn test_sanctum_lz4_compression_efficiency() {
|
|||||||
let password = "CompressTestPassword2026!";
|
let password = "CompressTestPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -333,8 +334,8 @@ async fn test_sanctum_v1_backward_compatibility() {
|
|||||||
let password = "V1LegacyPassword!";
|
let password = "V1LegacyPassword!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -392,8 +393,8 @@ async fn test_sanctum_disaster_recovery_workflow() {
|
|||||||
let initial_password = "PrimaryPassword2026!";
|
let initial_password = "PrimaryPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
|
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
|
||||||
@@ -511,8 +512,8 @@ async fn test_anti_leak_and_inactivity_shield() {
|
|||||||
let password = "ShieldedContainerPass2026!";
|
let password = "ShieldedContainerPass2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
|
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
|
||||||
@@ -625,8 +626,8 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
|
|||||||
let salt0 = generate_salt();
|
let salt0 = generate_salt();
|
||||||
let salt1 = generate_salt();
|
let salt1 = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -812,8 +813,8 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards()
|
|||||||
let pass_decoy = "OuterDecoyPassphrase2026!";
|
let pass_decoy = "OuterDecoyPassphrase2026!";
|
||||||
let pass_hidden = "InnerHiddenVaultPass2026!";
|
let pass_hidden = "InnerHiddenVaultPass2026!";
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -990,8 +991,8 @@ async fn test_sanctum_online_backup_and_restore() {
|
|||||||
let password = "BackupTestPassword2026!";
|
let password = "BackupTestPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -1079,8 +1080,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
let password = "WebDavSecPassword2026!";
|
let password = "WebDavSecPassword2026!";
|
||||||
let salt = generate_salt();
|
let salt = generate_salt();
|
||||||
let kdf_params = KdfParams {
|
let kdf_params = KdfParams {
|
||||||
memory_cost: 1024,
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
time_cost: 1,
|
time_cost: MIN_TIME_COST,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
};
|
};
|
||||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||||
@@ -1093,10 +1094,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
|
|
||||||
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
||||||
let session_token = "deadbeefcafebabe0123456789abcdef";
|
let session_token = "deadbeefcafebabe0123456789abcdef";
|
||||||
let token_path_prefix = format!("/{}", session_token);
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -1108,11 +1107,11 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||||
listener,
|
listener,
|
||||||
dav_server,
|
dav_server,
|
||||||
token_path_prefix.clone(),
|
session_token.to_string(),
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
|
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 401 Unauthorized mit WWW-Authenticate
|
||||||
{
|
{
|
||||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
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());
|
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
|
||||||
@@ -1121,8 +1120,13 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
stream.read_to_end(&mut resp).await.unwrap();
|
stream.read_to_end(&mut resp).await.unwrap();
|
||||||
let resp_str = String::from_utf8_lossy(&resp);
|
let resp_str = String::from_utf8_lossy(&resp);
|
||||||
assert!(
|
assert!(
|
||||||
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
|
resp_str.starts_with("HTTP/1.1 401 Unauthorized"),
|
||||||
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
|
"Anfrage ohne Session-Token muss mit 401 Unauthorized abgewiesen werden, erhalten:\n{}",
|
||||||
|
resp_str
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp_str.to_lowercase().contains("www-authenticate: basic realm=\"sanctum\""),
|
||||||
|
"401-Antwort muss WWW-Authenticate Header enthalten, erhalten:\n{}",
|
||||||
resp_str
|
resp_str
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1142,7 +1146,7 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
|
// 5. Legitimer Zugriff: Korrektes Session-Token via Path-Prefix Fallback -> 200 OK
|
||||||
{
|
{
|
||||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
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());
|
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
|
||||||
@@ -1161,6 +1165,23 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Legitimer Zugriff: HTTP Basic Auth (R-05) -> 200 OK
|
||||||
|
{
|
||||||
|
use base64::Engine;
|
||||||
|
let auth_hdr = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token)));
|
||||||
|
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let req = format!("OPTIONS / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAuthorization: {}\r\nConnection: close\r\n\r\n", addr.port(), auth_hdr);
|
||||||
|
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 Basic Auth muss 200 OK liefern, erhalten:\n{}",
|
||||||
|
resp_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Server ordnungsgemäß beenden & aufräumen
|
// Server ordnungsgemäß beenden & aufräumen
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mount_security_multi_auth_and_no_token_in_url() {
|
||||||
|
use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix};
|
||||||
|
|
||||||
|
let session_token = "4f8a12bc90de45f187a23456789abcde";
|
||||||
|
let port = 8443;
|
||||||
|
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||||
|
|
||||||
|
// R-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
|
||||||
|
assert!(!remote_url.contains(session_token), "Remote URL darf niemals das Session-Token enthalten");
|
||||||
|
|
||||||
|
// 1. Basic Auth Prüfung
|
||||||
|
use base64::Engine;
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))
|
||||||
|
);
|
||||||
|
assert!(check_basic_auth(&auth_header, session_token));
|
||||||
|
|
||||||
|
// 2. Token Header Prüfung
|
||||||
|
let mut headers = hyper::HeaderMap::new();
|
||||||
|
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
||||||
|
assert!(check_token_header(&headers, session_token));
|
||||||
|
|
||||||
|
// 3. Path-Prefix Fallback
|
||||||
|
let prefix = format!("/{}", session_token);
|
||||||
|
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
|
||||||
|
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
|
||||||
|
assert_eq!(stripped.path(), "/test.txt");
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_storage_create_and_rename_reject_invalid_names() {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("sanctum_test_pathutil_{}.sanctum", std::process::id()));
|
||||||
|
if path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = sanctum::crypto::generate_salt();
|
||||||
|
let kdf_params = sanctum::crypto::KdfParams {
|
||||||
|
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: sanctum::crypto::MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = sanctum::crypto::derive_kek("Pass1234!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = sanctum::crypto::generate_dek();
|
||||||
|
let (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = sanctum::storage::Database::open(&path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag).unwrap();
|
||||||
|
|
||||||
|
// create_node_in_vault mit ungültigem Namen muss scheitern
|
||||||
|
assert!(db.create_node_in_vault(0, 1, "..", false, &dek).is_err());
|
||||||
|
assert!(db.create_node_in_vault(0, 1, "CON", false, &dek).is_err());
|
||||||
|
assert!(db.create_node_in_vault(0, 1, "sub/dir", false, &dek).is_err());
|
||||||
|
|
||||||
|
// Gültige Datei erstellen
|
||||||
|
let valid_node = db.create_node_in_vault(0, 1, "valid.txt", false, &dek).unwrap();
|
||||||
|
|
||||||
|
// rename_node_in_vault mit ungültigem Namen muss scheitern
|
||||||
|
assert!(db.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek).is_err());
|
||||||
|
assert!(db.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek).is_err());
|
||||||
|
assert!(db.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek).is_err());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
|
||||||
|
let carrier_node = db.get_node_by_id(carrier_node_id).unwrap().unwrap();
|
||||||
|
let local_dest_file = std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
|
||||||
|
let pull_single_res = sanctum::sync::sync_single_file_to_host(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek_0,
|
||||||
|
sanctum::crypto::FORMAT_VERSION,
|
||||||
|
&carrier_node,
|
||||||
|
&local_dest_file,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert!(pull_single_res.is_err(), "sync_single_file_to_host auf Carrier muss fehlschlagen");
|
||||||
|
assert!(!local_dest_file.exists(), "Trägerdatei darf niemals auf den Host geschrieben werden");
|
||||||
|
|
||||||
|
// 6. R-02: run_sync mit Pull muss die Trägerdatei überspringen
|
||||||
|
let local_pull_dir = std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
|
||||||
|
let sync_options = sanctum::sync::SyncOptions {
|
||||||
|
direction: sanctum::sync::SyncDirection::Pull,
|
||||||
|
delete: false,
|
||||||
|
dry_run: false,
|
||||||
|
checksum: false,
|
||||||
|
exclude_patterns: Vec::new(),
|
||||||
|
quiet: true,
|
||||||
|
};
|
||||||
|
let pull_res = sanctum::sync::run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek_0,
|
||||||
|
sanctum::crypto::FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
&local_pull_dir.to_string_lossy(),
|
||||||
|
&sync_options,
|
||||||
|
);
|
||||||
|
assert!(pull_res.is_ok(), "run_sync pull muss erfolgreich durchlaufen");
|
||||||
|
let pulled_carrier = local_pull_dir.join(carrier_name);
|
||||||
|
assert!(!pulled_carrier.exists(), "Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden");
|
||||||
|
let _ = std::fs::remove_dir_all(&local_pull_dir);
|
||||||
|
|
||||||
|
// 7. R-02: VFS Copy Schutz
|
||||||
|
let fs = sanctum::vfs::SanctumFs::with_carrier(
|
||||||
|
db.clone(),
|
||||||
|
dek_0.clone(),
|
||||||
|
Some(dek_0.clone()),
|
||||||
|
Some(carrier_node_id),
|
||||||
|
sanctum::crypto::FORMAT_VERSION,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
use dav_server::davpath::DavPath;
|
||||||
|
use dav_server::fs::DavFileSystem;
|
||||||
|
// Copy Quelle = Carrier
|
||||||
|
let from_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
||||||
|
let to_copy = DavPath::new("/carrier_copy.iso").unwrap();
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
|
||||||
|
assert!(copy_src_res.is_err(), "SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern");
|
||||||
|
|
||||||
|
// Copy Ziel = Carrier
|
||||||
|
let _regular_file = db.create_node_in_vault(0, 1, "regular.txt", false, &dek_0).unwrap();
|
||||||
|
let from_regular = DavPath::new("/regular.txt").unwrap();
|
||||||
|
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
||||||
|
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
|
||||||
|
assert!(copy_dst_res.is_err(), "SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||||
|
MIN_TIME_COST,
|
||||||
|
};
|
||||||
|
use sanctum::storage::Database;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_arbitrary_sqlite_database() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_foreign_sqlite_{}.db", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
|
||||||
|
let res = Database::open(&db_path);
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("kein gültiger Sanctum-Container"),
|
||||||
|
"Fehler muss ungültigen Container abweisen: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Fremde SQLite-DB ohne meta-Tabelle muss abgelehnt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_sqlite_database_with_invalid_magic() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_invalid_magic_{}.db", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle SQLite DB mit 'meta' Tabelle, aber falscher Magic
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER PRIMARY KEY,
|
||||||
|
magic BLOB NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL,
|
||||||
|
kdf_params TEXT NOT NULL,
|
||||||
|
wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL,
|
||||||
|
header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
||||||
|
VALUES (0, ?1, 2, ?2, '{}', ?3, ?4, ?5)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"EVIL_MAGIC",
|
||||||
|
&[0u8; 16][..],
|
||||||
|
&[0u8; 40][..],
|
||||||
|
&[0u8; 12][..],
|
||||||
|
&[0u8; 16][..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = Database::open(&db_path);
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("kein gültiger Sanctum-Container"),
|
||||||
|
"Fehler muss ungültigen Magic-Header monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Container mit ungültigen Magic Bytes muss abgelehnt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_out_of_bounds_kdf_params() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_oob_kdf_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initialisiere gültigen Container
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// 2. Manipuliere KDF-Parameter in der meta-Tabelle auf gefährliche Werte (DoS: 16 GiB Memory)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 16777216, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Öffnen und read_slots() aufrufen: muss mit Kdf-Param-Fehler abbrechen
|
||||||
|
let db_reopened = Database::open(&db_path).unwrap();
|
||||||
|
let slots_res = db_reopened.read_slots();
|
||||||
|
match slots_res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("KDF-Parameter"),
|
||||||
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Überhöhte KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Manipuliere KDF-Parameter auf zu schwache Werte (< MIN_MEMORY_COST_KIB)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 1024, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let slots_res2 = db_reopened.read_slots();
|
||||||
|
match slots_res2 {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("KDF-Parameter"),
|
||||||
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Zu schwache KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_corrupted_slot_lengths() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
|
||||||
|
if db_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("ValidPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// Manipuliere Salt-Länge auf 8 Byte statt 16 Byte
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE meta SET kdf_salt = ?1 WHERE slot_id = 0",
|
||||||
|
[&[0u8; 8][..]],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_reopened = Database::open(&db_path).unwrap();
|
||||||
|
let res = db_reopened.read_slots();
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("Salt-Länge"),
|
||||||
|
"Fehler muss fehlerhafte Salt-Länge monieren: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Ungültige Salt-Länge muss abgewiesen werden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use sanctum::upgrade::{
|
||||||
|
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_valid() {
|
||||||
|
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_tampered_payload() {
|
||||||
|
let tampered_payload = b"Hello Sanctum Release 0.7.0 Tampered Payload!\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
||||||
|
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
|
||||||
|
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minisign_signature_wrong_key() {
|
||||||
|
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
|
||||||
|
let sig_text = "\
|
||||||
|
untrusted comment: signature from minisign secret key\n\
|
||||||
|
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
|
||||||
|
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
||||||
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
|
// Ein anderer, unautorisierter Public Key
|
||||||
|
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
|
||||||
|
let res = verify_minisign_signature(payload, sig_text, other_key);
|
||||||
|
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
|
||||||
|
let options = UpgradeOptions {
|
||||||
|
check_only: true,
|
||||||
|
yes: false,
|
||||||
|
force: false,
|
||||||
|
base_url: "https://evil-attacker.org/sanctum".into(),
|
||||||
|
insecure_url: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = run_upgrade(&options);
|
||||||
|
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
|
||||||
|
let err_msg = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
|
||||||
|
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user