Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba7f305e3 | ||
|
|
1cdb30147b | ||
|
|
436790abf0 | ||
|
|
80a3bd1911 | ||
|
|
2da1ea55a9 | ||
|
|
2e4cf1cef0 | ||
|
|
1048253497 | ||
|
|
aa65d96434 |
@@ -6,16 +6,18 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets
|
||||
build:
|
||||
name: Build & Release (Windows x86_64)
|
||||
name: Build & Test (Windows x86_64)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned
|
||||
with:
|
||||
toolchain: "1.85.0"
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Run Tests
|
||||
@@ -47,18 +49,79 @@ jobs:
|
||||
|
||||
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
|
||||
|
||||
|
||||
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
||||
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Build Artifacts
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: dist-artifacts
|
||||
path: dist/
|
||||
|
||||
# SA-02, SA-03 & SA-04: Dedizierter Signatur- & Release-Job mit isolierten Secrets und Prüfsummen-Verifikation
|
||||
sign-and-release:
|
||||
name: Sign & Release
|
||||
needs: [build]
|
||||
runs-on: windows-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Download Build Artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: dist-artifacts
|
||||
path: dist
|
||||
|
||||
- name: Verify and Sign Checksums with Minisign
|
||||
shell: pwsh
|
||||
env:
|
||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||
run: |
|
||||
# SA-02: Secret-Isolation — MINISIGN_SECRET_KEY ist ausschließlich in diesem dedizierten Signier-Job verfügbar
|
||||
if (-not $env:MINISIGN_SECRET_KEY) {
|
||||
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# SA-03: Pinned Download-Integrität für minisign.exe mit zwingender SHA-256 Validierung
|
||||
$MinisignUrl = "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip"
|
||||
$ExpectedMinisignHash = "b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031"
|
||||
|
||||
if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Lade gepinnten Minisign-Release herunter: $MinisignUrl"
|
||||
Invoke-WebRequest -Uri $MinisignUrl -OutFile "minisign.zip"
|
||||
|
||||
$ActualHash = (Get-FileHash -Path "minisign.zip" -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ActualHash -ne $ExpectedMinisignHash) {
|
||||
Write-Error "KRITISCHER SICHERHEITSFEHLER: Minisign SHA-256 Prüfsumme ungültig!`nErwartet: $ExpectedMinisignHash`nErhalten: $ActualHash`nAbbruch."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Minisign SHA-256 Integrität erfolgreich verifiziert ($ActualHash)."
|
||||
|
||||
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
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
|
||||
with:
|
||||
files: |
|
||||
dist/*.zip
|
||||
dist/SHA256SUMS.txt
|
||||
target/release/sanctum.exe
|
||||
dist/SHA256SUMS.txt.minisig
|
||||
body_path: CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -6,3 +6,5 @@
|
||||
.env.*
|
||||
*.token
|
||||
.token
|
||||
*.key
|
||||
sanctum-release.key
|
||||
|
||||
+129
@@ -5,6 +5,135 @@ 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/)
|
||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [0.7.2] - 2026-09-18
|
||||
|
||||
### Security Audit Remediation Release (Findings SA-01 to SA-07)
|
||||
Dieses Release behebt alle sieben identifizierten Schwachstellen und Härtungsanforderungen (SA-01 bis SA-07) aus dem Security-Audit von v0.7.1.
|
||||
|
||||
#### Security & Architecture
|
||||
- **SA-01: Container-DoS / KDF-Amplification Schutz**:
|
||||
- Pre-KDF strukturelle Validierung in `Database::open`, `read_slots`, `ContainerMeta::authenticate` und `recovery::to_meta`.
|
||||
- Striktes Limit: Maximal 2 Slots (`slots.len() <= 2`), nur Slot-IDs 0 und 1 zulässig, Erkennung von Duplikaten, Slot 0 ist Pflicht.
|
||||
- Strikte Validierung aller BLOB-Längen (Salt: 16 B, Nonce: 12 B, Tag: 16 B, Wrapped DEK: 32/40 B für Slot 0, 32/64/72 B für Slot 1) und KDF-Parametergrenzen vor jeder Argon2id-Ableitung (`derive_kek`).
|
||||
- **SA-02: Release-Signierung entkoppelt & CI-Secrets isoliert**:
|
||||
- Trennung des CI/CD-Release-Workflows (`release.yaml`) in separate `build`- und `sign-and-release`-Jobs.
|
||||
- Das Secret `MINISIGN_SECRET_KEY` ist strikt auf den nachgelagerten Signatur-Job isoliert und für Build-/Test-Runner unzugänglich.
|
||||
- **SA-03: Pinned Download-Integrität für `minisign.exe`**:
|
||||
- SHA-256 Integritätsprüfung des offiziellen Minisign v0.11 Downloads (`b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031`) im CI-Workflow vor dem Entpacken und Ausführen (Fail-Closed).
|
||||
- **SA-04: Immutable Action-Pinning & Toolchain-Pinning**:
|
||||
- Alle externen GitHub/Gitea-Actions im Release-Workflow auf unveränderliche Commit-SHAs (`@<sha>`) fixiert.
|
||||
- Rust-Toolchain explizit auf Version `1.85.0` gepinnt.
|
||||
- **SA-05: Session-Tokens vollständig aus URIs verbannt**:
|
||||
- Eingehende WebDAV-Requests, deren URI das Session-Token in Pfad oder Query-String enthalten (`/<token>/...` oder `?token=...`), werden sofort mit `403 Forbidden` abgewiesen.
|
||||
- Authentifizierung wird strikt über HTTP-Header (`Authorization: Basic ...` oder `X-Sanctum-Token: ...`) erzwungen.
|
||||
- **SA-06: Constant-Time Token-Vergleich**:
|
||||
- Integration von `subtle::ConstantTimeEq` für zeitinvariante Vergleiche von Basic-Auth-Credentials und Session-Tokens zum Schutz vor Timing-Seitenkanalangriffen.
|
||||
- **SA-07: Dokumentations-Klarstellung: Logische vs. Physische Datenlöschung**:
|
||||
- Präzisierung im `README.md` und Code-Docstrings bzgl. der Grenzen des kryptografischen Chunk-Shreddings: Auf Flash-Medien (SSDs/NVMe) können Wear-Leveling und FTL-Blöcke physisch persistieren. Empfehlung zur Nutzung von Full-Disk-Encryption (FDE).
|
||||
|
||||
## [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
|
||||
|
||||
Generated
+171
-1
@@ -512,6 +512,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1020,6 +1026,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -1090,6 +1102,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -1334,6 +1352,20 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
@@ -1369,6 +1401,54 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1377,11 +1457,12 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.4.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"bip39",
|
||||
"bytes",
|
||||
"clap",
|
||||
@@ -1393,16 +1474,23 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"lz4_flex",
|
||||
"minisign-verify",
|
||||
"rand",
|
||||
"rpassword",
|
||||
"rusqlite",
|
||||
"self-replace",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tray-item",
|
||||
"ureq",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1412,6 +1500,23 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1466,6 +1571,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1564,6 +1680,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
@@ -1780,6 +1909,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1884,6 +2036,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.4.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
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"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
rpassword = "7.3"
|
||||
argon2 = { version = "0.5", features = ["password-hash"] }
|
||||
@@ -36,6 +37,13 @@ dyn-clone = "1.0"
|
||||
lz4_flex = "0.11"
|
||||
bip39 = { version = "2.2", features = ["zeroize"] }
|
||||
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"
|
||||
subtle = "2.6"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tray-item = "0.10"
|
||||
@@ -44,5 +52,5 @@ tray-item = "0.10"
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
panic = "unwind"
|
||||
strip = true
|
||||
|
||||
@@ -28,23 +28,20 @@ Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrische
|
||||
|
||||
---
|
||||
|
||||
## 2. Plausible Deniability & Herausgabepflichten (Key Disclosure Laws)
|
||||
## 2. Dual-Vault-Architektur (Second Safe) & Herausgabepflichten (Key Disclosure Laws)
|
||||
|
||||
Sanctum implementiert mit **Modell A** (Steganografischer Alibi-Carrier im Decoy-Vault mit exakter physikalischer Dateigrößen-Invarianz und uniformem Entropie-Slack) ein mathematisch informationstheoretisches Modell für **Plausible Deniability** (Glaubhafte Abstreitbarkeit).
|
||||
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 Hinweis für Anwender**: Plausible Deniability ist ein **technischer Schutzmechanismus**, kein juristisches Schutzschild. Die Wirksamkeit gegenüber staatlichen Ermittlungsbehörden hängt maßgeblich von der jeweiligen Jurisdiktion und dem eigenen Aussageverhalten ab:
|
||||
|
||||
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 Recht, Passwörter und Schlüssel vollständig zu verschweigen.
|
||||
* **Aussage als Beschuldigter**: Gibt ein Beschuldigter freiwillig das Passwort für den Decoy-Vault (Slot 0) heraus und verschweigt die Existenz des Hidden Vaults (Slot 1), macht er sich in der Regel nicht strafbar, solange er keine Dritten falsch verdächtigt (§ 164 StGB).
|
||||
* **Zeugenstatus**: Zeugen sind grundsätzlich zur Wahrheit verpflichtet (§ 70 StPO). Bei falscher Aussage droht Strafbarkeit (§§ 153 ff. StGB).
|
||||
2. **Großbritannien (UK - RIPA 2000 Part III, Section 49 & 53)**:
|
||||
* Britische Behörden können unter Androhung einer Freiheitsstrafe von bis zu 2 Jahren (bei Gefährdung der nationalen Sicherheit bis zu 5 Jahren) die Offenlegung von Schlüsseln förmlich anordnen (*Section 49 Notice*).
|
||||
* Da Sanctum dank Modell A keine forensisch nachweisbaren Spuren eines Hidden Vaults hinterlässt, existiert für Ermittler kein Nachweis, dass überhaupt ein zweiter Schlüssel existiert. Dennoch obliegt die Beweiswürdigung dem zuständigen Gericht.
|
||||
3. **Vereinigte Staaten von Amerika (USA - 5th Amendment)**:
|
||||
* Das 5. Zusatzprotokoll schützt vor erzwungenen Zeugenaussagen gegen sich selbst. Passwörter gelten als geschützter Denkinhalt (*testimonial*).
|
||||
* *Foregone Conclusion Doctrine*: Kann der Staat beweisen, dass bestimmte inkriminierte Dateien auf dem Gerät existieren, kann die Passworteingabe als Vollstreckungshandlung erzwungen werden. Bei Sanctum verhindert die ununterscheidbare Entropie des Trägers den Nachweis der Existenz von Daten im Hidden Safe.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,7 +64,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
|
||||
|
||||
Sanctum folgt uneingeschränkt dem Prinzip **Privacy by Design and by Default** (Art. 25 DSGVO):
|
||||
|
||||
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie.
|
||||
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
|
||||
* **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.
|
||||
|
||||
+4
-3
@@ -29,13 +29,14 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
||||
```powershell
|
||||
sanctum.exe init --path "D:\Tresor\daten.sanctum"
|
||||
```
|
||||
* **Plausible Deniability Tresor (Dual-Vault mit Alibi-Carrier)**:
|
||||
* **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:
|
||||
* 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, unnachweisbaren Safe.
|
||||
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!
|
||||
|
||||
---
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
|
||||
# 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).
|
||||
- **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.
|
||||
- **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`).
|
||||
- **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).
|
||||
|
||||
@@ -27,18 +29,19 @@ 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.
|
||||
- **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.
|
||||
- **Speichersicherheit (Zeroize)**:
|
||||
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.
|
||||
- **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. Schlüsseldaten werden mittels `VirtualLock` / `mlock` vor Paging geschützt.
|
||||
- **Chunk-Verschlüsselung (AES-256-GCM)**:
|
||||
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
||||
- **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.
|
||||
- **Plausible Deniability (Multi-Slot)**:
|
||||
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.
|
||||
- **Dual-Vault (Multi-Slot & Carrier)**:
|
||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
||||
- **Dateinamen-Verschlüsselung**:
|
||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert.
|
||||
- **Kryptografisches Chunk-Shredding**:
|
||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
|
||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene.
|
||||
*Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +81,7 @@ Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
|
||||
# Standard-Container anlegen:
|
||||
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
|
||||
```
|
||||
*(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).*
|
||||
@@ -169,6 +172,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
|
||||
|
||||
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
||||
@@ -211,9 +267,10 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
||||
```
|
||||
|
||||
Erzeugt:
|
||||
- `dist/sanctum-v0.4.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
||||
- `dist/sanctum-v0.4.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
||||
- `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.minisig` (Kryptografische Minisign-Release-Signatur)
|
||||
|
||||
---
|
||||
|
||||
@@ -221,8 +278,8 @@ Erzeugt:
|
||||
|
||||
- **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.
|
||||
- **Plausible Deniability & Herausgabepflichten**:
|
||||
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) ein informationstheoretisches Modell für glaubhafte Abstreitbarkeit. Dies ist ein technischer Schutzmechanismus, kein juristisches Schutzschild. Die rechtliche Bewertung im Fall strafprozessualer Herausgabeanordnungen (*Key Disclosure*) hängt von der jeweiligen Jurisdiktion ab (z. B. Nemo-tenetur-Grundsatz in DACH vs. RIPA Section 49 im UK).
|
||||
- **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)**:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.4.1",
|
||||
"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.4.1/sanctum-v0.4.1-windows-x86_64.zip",
|
||||
"hash": "dc0980312857505a078a3693d81a5d16853c3a94152f9104004dc6c4fd929bf7",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||
PackageIdentifier: HaraldPansi.Sanctum
|
||||
PackageVersion: 0.4.1
|
||||
PackageVersion: 0.6.0
|
||||
PackageName: Sanctum
|
||||
Publisher: Harald Pansi
|
||||
PublisherUrl: https://gitea.pansi.eu/harald
|
||||
@@ -18,7 +18,7 @@ Tags:
|
||||
- webdav
|
||||
- container
|
||||
- plausible-deniability
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.1
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
@@ -26,7 +26,7 @@ Installers:
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: sanctum.exe
|
||||
PortableCommandAlias: sanctum
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.1/sanctum-v0.4.1-windows-x86_64.zip
|
||||
InstallerSha256: dc0980312857505a078a3693d81a5d16853c3a94152f9104004dc6c4fd929bf7
|
||||
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
|
||||
@@ -106,6 +106,31 @@ $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)
|
||||
|
||||
@@ -116,4 +141,7 @@ 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."
|
||||
|
||||
@@ -101,6 +101,31 @@ $AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
|
||||
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
|
||||
$ZipSize = (Get-Item $ZipFile).Length / 1MB
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
@@ -110,4 +135,7 @@ Write-Host " Archiv: dist\$PackageName.zip ($([math]::Round($ZipSize, 2)
|
||||
Write-Host " ZIP SHA-256: $ZipHash"
|
||||
Write-Host " EXE SHA-256: $ExeHash"
|
||||
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"
|
||||
|
||||
@@ -27,6 +27,12 @@ if (-not (Test-Path $ZipFile)) {
|
||||
& (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)
|
||||
$EnvFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path $EnvFile) {
|
||||
@@ -155,8 +161,10 @@ Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_6
|
||||
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
|
||||
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
|
||||
|
||||
# SHA256SUMS.txt hochladen
|
||||
# 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
|
||||
|
||||
+11
-18
@@ -10,8 +10,8 @@ use bytes::{Buf, Bytes, BytesMut};
|
||||
use dav_server::{
|
||||
davpath::DavPath,
|
||||
fs::{
|
||||
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream,
|
||||
OpenOptions, ReadDirMeta,
|
||||
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, OpenOptions,
|
||||
ReadDirMeta,
|
||||
},
|
||||
};
|
||||
use futures_util::stream;
|
||||
@@ -624,9 +624,7 @@ impl DavFileSystem for CarrierFs {
|
||||
};
|
||||
|
||||
inner.manifest.inodes.insert(new_id, new_dir);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -659,9 +657,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -690,9 +686,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -748,9 +742,7 @@ impl DavFileSystem for CarrierFs {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -1011,9 +1003,7 @@ impl DavFile for CarrierFile {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
@@ -1025,7 +1015,10 @@ impl DavFile for CarrierFile {
|
||||
impl Drop for CarrierFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.flush_cached_block() {
|
||||
tracing::warn!("Fehler beim automatischen Flush im CarrierFile::drop: {:?}", e);
|
||||
tracing::warn!(
|
||||
"Fehler beim automatischen Flush im CarrierFile::drop: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Some((_, ref mut data, _)) = self.cached_block {
|
||||
data.zeroize();
|
||||
|
||||
+297
-79
@@ -40,12 +40,70 @@ 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.
|
||||
pub fn derive_kek(
|
||||
password: &str,
|
||||
salt: &[u8],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zeroizing<[u8; 32]>> {
|
||||
validate_kdf_params(params)?;
|
||||
|
||||
let argon2_params = Params::new(
|
||||
params.memory_cost,
|
||||
params.time_cost,
|
||||
@@ -56,10 +114,13 @@ pub fn derive_kek(
|
||||
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
let mut kek = Zeroizing::new([0u8; 32]);
|
||||
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
|
||||
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), salt, &mut *kek)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
|
||||
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
|
||||
if let Err(e) = res {
|
||||
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
|
||||
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
|
||||
}
|
||||
|
||||
Ok(kek)
|
||||
}
|
||||
@@ -67,6 +128,7 @@ pub fn derive_kek(
|
||||
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
|
||||
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
||||
let mut dek = Zeroizing::new([0u8; 32]);
|
||||
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
||||
OsRng.fill_bytes(&mut *dek);
|
||||
dek
|
||||
}
|
||||
@@ -79,10 +141,7 @@ pub fn generate_salt() -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
|
||||
pub fn wrap_key_payload(
|
||||
kek: &[u8; 32],
|
||||
payload: &[u8],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
|
||||
@@ -117,17 +176,18 @@ pub fn unwrap_key_payload(
|
||||
let mut buffer = wrapped_payload.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Zeroizing::new(buffer))
|
||||
}
|
||||
|
||||
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
|
||||
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
|
||||
pub fn wrap_dek(
|
||||
kek: &[u8; 32],
|
||||
dek: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
wrap_key_payload(kek, dek)
|
||||
}
|
||||
|
||||
@@ -140,7 +200,10 @@ pub fn unwrap_dek(
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
|
||||
if payload.len() < 32 {
|
||||
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
|
||||
bail!(
|
||||
"Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut dek = Zeroizing::new([0u8; 32]);
|
||||
@@ -175,8 +238,7 @@ pub fn wrap_slot1_payload(
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
|
||||
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload) für Dummy-Slot-Längenparität im Header.
|
||||
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
|
||||
let mut wrapped_dek = vec![0u8; 72];
|
||||
let mut nonce = [0u8; 12];
|
||||
@@ -200,6 +262,18 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
||||
aad
|
||||
}
|
||||
|
||||
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
|
||||
pub fn set_allow_legacy_names(allow: bool) {
|
||||
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn allows_legacy_names() -> bool {
|
||||
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
|
||||
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
|
||||
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
|
||||
@@ -207,12 +281,20 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
||||
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
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 aad = build_name_aad(parent_id);
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
|
||||
.expect("Name encryption");
|
||||
let tag = match cipher.encrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
&aad,
|
||||
&mut buffer,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
combined.extend_from_slice(tag.as_slice());
|
||||
@@ -221,9 +303,14 @@ 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.
|
||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
|
||||
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
|
||||
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
|
||||
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
|
||||
pub fn decrypt_node_name_ext(
|
||||
dek: &[u8; 32],
|
||||
parent_id: i64,
|
||||
stored: &str,
|
||||
allow_legacy: bool,
|
||||
) -> Option<String> {
|
||||
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
||||
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||
let parts: Vec<&str> = rest.split('$').collect();
|
||||
@@ -250,7 +337,8 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
||||
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;
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
@@ -266,6 +354,7 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -293,7 +382,8 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
||||
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();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
@@ -310,9 +400,14 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
||||
@@ -335,7 +430,11 @@ pub fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
|
||||
for i in 1..=m {
|
||||
for j in 1..=n {
|
||||
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
|
||||
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);
|
||||
@@ -383,10 +482,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
cleaned
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.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.
|
||||
@@ -412,9 +508,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
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)
|
||||
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)
|
||||
format!(
|
||||
"Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)",
|
||||
idx + 1,
|
||||
word
|
||||
)
|
||||
};
|
||||
invalid_words.push(msg);
|
||||
}
|
||||
@@ -532,7 +637,11 @@ pub fn decrypt_chunk(
|
||||
let mut buffer = ciphertext.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
|
||||
)
|
||||
})?;
|
||||
|
||||
if format_version >= FORMAT_VERSION_V2 {
|
||||
if buffer.is_empty() {
|
||||
@@ -545,7 +654,9 @@ pub fn decrypt_chunk(
|
||||
if payload.len() < 4 {
|
||||
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 {
|
||||
bail!(
|
||||
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
||||
@@ -564,7 +675,6 @@ pub fn decrypt_chunk(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -574,8 +684,8 @@ mod tests {
|
||||
let password = "SuperSecretMasterPassword123!";
|
||||
let salt = generate_salt();
|
||||
let params = KdfParams {
|
||||
memory_cost: 1024, // Schneller für Unit-Test
|
||||
time_cost: 1,
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
@@ -610,26 +720,55 @@ mod tests {
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
||||
|
||||
// Reguläre Entschlüsselung (v2)
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
||||
let swap_node_err =
|
||||
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
let swap_node_err = decrypt_chunk(
|
||||
&dek,
|
||||
99i64,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(swap_node_err.is_err());
|
||||
|
||||
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
||||
let swap_idx_err =
|
||||
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
let swap_idx_err = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
1u32,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(swap_idx_err.is_err());
|
||||
|
||||
// Manipulation des Ciphertexts
|
||||
let mut tampered_ct = ciphertext.clone();
|
||||
tampered_ct[0] ^= 0x01;
|
||||
assert!(
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
|
||||
);
|
||||
assert!(decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&tampered_ct,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,8 +791,16 @@ mod tests {
|
||||
plaintext.len()
|
||||
);
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
@@ -664,8 +811,7 @@ mod tests {
|
||||
let mut random_bytes = vec![0u8; 1000];
|
||||
OsRng.fill_bytes(&mut random_bytes);
|
||||
|
||||
let (ct, nonce, tag) =
|
||||
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
||||
let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
||||
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
||||
assert_eq!(ct.len(), random_bytes.len() + 1);
|
||||
|
||||
@@ -685,8 +831,16 @@ mod tests {
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
|
||||
assert_eq!(ciphertext.len(), plaintext.len());
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
@@ -698,10 +852,17 @@ mod tests {
|
||||
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
|
||||
|
||||
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
|
||||
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
|
||||
assert_eq!(
|
||||
*dek, *recovered_dek,
|
||||
"Recovered DEK must match original DEK"
|
||||
);
|
||||
|
||||
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
|
||||
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
|
||||
let messy_phrase = format!(
|
||||
" {} \n\t {} ",
|
||||
words[0..12].join(" "),
|
||||
words[12..24].join(" \n ")
|
||||
);
|
||||
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
|
||||
assert_eq!(*dek, *recovered_messy);
|
||||
}
|
||||
@@ -725,9 +886,16 @@ mod tests {
|
||||
.collect();
|
||||
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
|
||||
let original_last = words[23].clone();
|
||||
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
|
||||
words[23] = if original_last == "abandon" {
|
||||
"zoo".to_string()
|
||||
} else {
|
||||
"abandon".to_string()
|
||||
};
|
||||
let corrupted_phrase = words.join(" ");
|
||||
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]
|
||||
@@ -770,8 +938,14 @@ mod tests {
|
||||
assert_eq!(decrypted, filename);
|
||||
|
||||
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
|
||||
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
|
||||
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
let legacy_format = format!(
|
||||
"$h${}${}${}",
|
||||
&encrypted[0..24],
|
||||
&encrypted[24..56],
|
||||
&encrypted[56..]
|
||||
);
|
||||
let decrypted_legacy =
|
||||
decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
assert_eq!(decrypted_legacy, filename);
|
||||
|
||||
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
|
||||
@@ -779,7 +953,11 @@ mod tests {
|
||||
let mut static_buf = filename.as_bytes().to_vec();
|
||||
let static_nonce = [42u8; 12];
|
||||
let static_tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
|
||||
.encrypt_in_place_detached(
|
||||
Nonce::from_slice(&static_nonce),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&mut static_buf,
|
||||
)
|
||||
.unwrap();
|
||||
let legacy_static_format = format!(
|
||||
"$h${}${}${}",
|
||||
@@ -787,8 +965,15 @@ mod tests {
|
||||
hex::encode(static_tag),
|
||||
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);
|
||||
set_allow_legacy_names(false);
|
||||
|
||||
// Mit anderem DEK schlägt Entschlüsselung fehl
|
||||
let other_dek = generate_dek();
|
||||
@@ -807,26 +992,43 @@ mod tests {
|
||||
let dek = generate_dek();
|
||||
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||
|
||||
// Gültige parent_ids entschlüsseln erfolgreich
|
||||
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
||||
assert_eq!(
|
||||
decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(),
|
||||
"secrets.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(),
|
||||
"passwords.txt"
|
||||
);
|
||||
|
||||
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
|
||||
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(
|
||||
decrypt_node_name(&dek, 20, &enc_folder_a).is_none(),
|
||||
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
|
||||
);
|
||||
assert!(
|
||||
decrypt_node_name(&dek, 10, &enc_folder_b).is_none(),
|
||||
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_a_slot_payloads() {
|
||||
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
||||
let kek_1 = derive_kek("HiddenPass123!", &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_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
let dek_1 = generate_dek();
|
||||
let carrier_node_id = 42i64;
|
||||
|
||||
// Slot 0 Payload: 40 Bytes
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_0, nonce_0, tag_0) =
|
||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
assert_eq!(wrapped_0.len(), 40);
|
||||
|
||||
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
|
||||
@@ -836,7 +1038,8 @@ mod tests {
|
||||
assert_eq!(recovered_cid_0, carrier_node_id);
|
||||
|
||||
// Slot 1 Payload: 72 Bytes
|
||||
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
assert_eq!(wrapped_1.len(), 72);
|
||||
|
||||
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
|
||||
@@ -868,15 +1071,30 @@ mod tests {
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut ct = malicious_plaintext.clone();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, &aad, &mut ct)
|
||||
.unwrap();
|
||||
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
|
||||
|
||||
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
|
||||
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
|
||||
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
|
||||
let res = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ct,
|
||||
&nonce_bytes,
|
||||
&tag_bytes,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"
|
||||
);
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
|
||||
assert!(
|
||||
err_msg.contains("Decompression-Bomb Schutz ausgelöst"),
|
||||
"Fehlermeldung erwartet: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
pub mod carrier;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod pathutil;
|
||||
pub mod platform;
|
||||
pub mod recovery;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod ui;
|
||||
pub mod upgrade;
|
||||
pub mod verify;
|
||||
pub mod vfs;
|
||||
pub mod windows;
|
||||
|
||||
+688
-135
File diff suppressed because it is too large
Load Diff
+320
-121
@@ -1,6 +1,7 @@
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -32,29 +33,11 @@ pub fn format_drive(drive_letter: char) -> String {
|
||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
|
||||
/// Trennt ein Windows-Netzlaufwerk via WNetCancelConnection2W bzw. Unix-Mountpoint via gio.
|
||||
pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let drive_str = format_drive(drive_letter);
|
||||
|
||||
let output = Command::new("net")
|
||||
.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
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
crate::windows::unmount_drive_wnet(drive_letter)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -68,74 +51,23 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein
|
||||
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
|
||||
fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
||||
/// Bindet ein Windows-Netzlaufwerk via in-process WNetAddConnection2W ein (R-05, kein Token in argv)
|
||||
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/`.
|
||||
fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
|
||||
|
||||
let mut output = Command::new("net")
|
||||
.args(["use", drive_str, &url, "/persistent:no"])
|
||||
.output()
|
||||
.context("Fehler beim Ausführen des Befehls 'net use'")?;
|
||||
|
||||
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
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();
|
||||
|
||||
// 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(())
|
||||
crate::windows::mount_drive_wnet(drive_letter, port, session_token)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
|
||||
let _ = Command::new("gio")
|
||||
.args(["mount", &dav_url])
|
||||
.output();
|
||||
let _ = drive_str;
|
||||
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
||||
let _ = Command::new("gio").args(["mount", &dav_url]).output();
|
||||
let _ = (drive_letter, session_token);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (drive_str, port, session_token);
|
||||
let _ = (drive_letter, port, session_token);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -155,6 +87,7 @@ pub async fn mount_container(
|
||||
stealth: bool,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
let _ = &drive_str;
|
||||
let _ = mount_point;
|
||||
let _ = open_explorer;
|
||||
let _ = enable_tray;
|
||||
@@ -171,8 +104,7 @@ pub async fn mount_container(
|
||||
println!();
|
||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||
}
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let meta = db
|
||||
.read_meta()
|
||||
@@ -181,12 +113,22 @@ pub async fn mount_container(
|
||||
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
|
||||
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) {
|
||||
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 ver = keys.version();
|
||||
@@ -203,19 +145,40 @@ pub async fn mount_container(
|
||||
if !stealth {
|
||||
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
||||
}
|
||||
let dek = mnemonic_to_dek(phrase)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
if !stealth {
|
||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||
}
|
||||
|
||||
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
|
||||
let is_hidden = {
|
||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
||||
!children.is_empty()
|
||||
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||
let (carrier_dek, final_carrier_node_id, vault_id) = if carrier_node_id > 0 {
|
||||
let slot_id = crate::recovery::detect_recovery_key_slot(&db, &dek, meta.version)?;
|
||||
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,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,7 +186,6 @@ pub async fn mount_container(
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||
let session_token = hex::encode(token_bytes);
|
||||
let token_path_prefix = format!("/{}", session_token);
|
||||
|
||||
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
||||
let fs = SanctumFs::with_carrier(
|
||||
@@ -237,7 +199,6 @@ pub async fn mount_container(
|
||||
);
|
||||
let last_activity = fs.last_activity();
|
||||
let dav_server = DavHandler::builder()
|
||||
.strip_prefix(token_path_prefix.clone())
|
||||
.filesystem(Box::new(fs))
|
||||
.locksystem(FakeLs::new())
|
||||
.build_handler();
|
||||
@@ -288,12 +249,12 @@ pub async fn mount_container(
|
||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||
listener,
|
||||
dav_server,
|
||||
token_path_prefix.clone(),
|
||||
session_token.clone(),
|
||||
shutdown_rx,
|
||||
));
|
||||
|
||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||
if let Err(e) = run_mount_command(&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 _ = server_handle.await;
|
||||
return Err(e);
|
||||
@@ -393,7 +354,10 @@ pub async fn mount_container(
|
||||
println!();
|
||||
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)
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(mp) = mount_point {
|
||||
@@ -402,12 +366,22 @@ pub async fn mount_container(
|
||||
println!(" • Modus: WebDAV Userland-VFS");
|
||||
}
|
||||
}
|
||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
|
||||
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, session_token);
|
||||
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, session_token, mp.display());
|
||||
println!(
|
||||
" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}",
|
||||
bound_port,
|
||||
mp.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(secs) = idle_timeout {
|
||||
@@ -429,9 +403,15 @@ pub async fn mount_container(
|
||||
}
|
||||
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!(
|
||||
" [{}] Drücke [Ctrl+C] zum sicheren Beenden.",
|
||||
ui::yellow("Tipp")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -439,11 +419,13 @@ pub async fn mount_container(
|
||||
#[cfg(windows)]
|
||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -556,7 +538,11 @@ pub async fn mount_container(
|
||||
} 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());
|
||||
|
||||
// Automatisches Unmount
|
||||
@@ -580,7 +566,11 @@ pub async fn mount_container(
|
||||
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
|
||||
if let Ok(freelist) = db.freelist_count() {
|
||||
if freelist > 0 {
|
||||
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
|
||||
print!(
|
||||
" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ",
|
||||
ui::dim("[-]"),
|
||||
freelist
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
match db.incremental_vacuum(None) {
|
||||
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
|
||||
@@ -599,18 +589,69 @@ pub async fn mount_container(
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
||||
println!(
|
||||
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
|
||||
ui::green("✔")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
drop(db);
|
||||
// S-10: Verifikation und Bereinigung von temporären SQLite WAL- und SHM-Dateien
|
||||
let base_os = container_path.as_os_str().to_os_string();
|
||||
let mut wal = base_os.clone();
|
||||
wal.push("-wal");
|
||||
let _ = std::fs::remove_file(wal);
|
||||
let mut shm = base_os;
|
||||
shm.push("-shm");
|
||||
let _ = std::fs::remove_file(shm);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ü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 {
|
||||
let host_lower = host_str.to_ascii_lowercase();
|
||||
host_lower.starts_with("127.0.0.1")
|
||||
|| host_lower.starts_with("localhost")
|
||||
|| host_lower.starts_with("[::1]")
|
||||
let host_trimmed = host_str.trim().to_ascii_lowercase();
|
||||
if host_trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if host_trimmed == "::1" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let host_part = if host_trimmed.starts_with('[') {
|
||||
// IPv6 bracketed: [::1] oder [::1]:port
|
||||
if let Some(bracket_end) = host_trimmed.find(']') {
|
||||
let inside = &host_trimmed[1..bracket_end];
|
||||
let after = &host_trimmed[bracket_end + 1..];
|
||||
if !after.is_empty() {
|
||||
if !after.starts_with(':') {
|
||||
return false;
|
||||
}
|
||||
if after[1..].parse::<u16>().is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
inside
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// IPv4 oder Hostname: localhost, localhost:8443, 127.0.0.1, 127.0.0.1:8443
|
||||
if let Some((h, p)) = host_trimmed.rsplit_once(':') {
|
||||
if p.parse::<u16>().is_ok() {
|
||||
h
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
&host_trimmed
|
||||
}
|
||||
};
|
||||
|
||||
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
||||
}
|
||||
|
||||
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||
@@ -619,14 +660,81 @@ const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
||||
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
|
||||
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
|
||||
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
|
||||
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
|
||||
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
|
||||
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,
|
||||
};
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
||||
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
|
||||
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
|
||||
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if let Some(val) = headers.get("X-Sanctum-Token") {
|
||||
if let Ok(val_str) = val.to_str() {
|
||||
return val_str
|
||||
.trim()
|
||||
.as_bytes()
|
||||
.ct_eq(expected_token.as_bytes())
|
||||
.into();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
|
||||
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
|
||||
if session_token.is_empty() {
|
||||
return false;
|
||||
}
|
||||
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
|
||||
}
|
||||
|
||||
/// 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 Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus.
|
||||
pub async fn serve_webdav_loop(
|
||||
listener: TcpListener,
|
||||
dav_server: DavHandler,
|
||||
token_path_prefix: String,
|
||||
session_token: String,
|
||||
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));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -654,14 +762,14 @@ pub async fn serve_webdav_loop(
|
||||
|
||||
let io = TokioIo::new(stream);
|
||||
let handler = dav_server.clone();
|
||||
let expected_prefix = token_path_prefix.clone();
|
||||
let expected_token = session_token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||
|
||||
let service = service_fn(move |req| {
|
||||
let h = handler.clone();
|
||||
let prefix = expected_prefix.clone();
|
||||
let token = expected_token.clone();
|
||||
async move {
|
||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||
let host_valid = match req.headers().get(hyper::header::HOST) {
|
||||
@@ -679,22 +787,46 @@ pub async fn serve_webdav_loop(
|
||||
);
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
|
||||
let path = req.uri().path();
|
||||
if !path.starts_with(&prefix) {
|
||||
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
|
||||
// 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598).
|
||||
// Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden.
|
||||
if uri_contains_token(req.uri(), &token) {
|
||||
warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)");
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
// 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich
|
||||
// a) HTTP Basic Auth (Authorization: Basic ...)
|
||||
// b) Header X-Sanctum-Token
|
||||
let mut authenticated = false;
|
||||
|
||||
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||
if let Ok(auth_str) = auth_val.to_str() {
|
||||
if check_basic_auth(auth_str, &token) {
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated && check_token_header(req.headers(), &token) {
|
||||
authenticated = true;
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
||||
return Ok::<_, Infallible>(unauthorized_response());
|
||||
}
|
||||
|
||||
Ok::<_, Infallible>(h.handle(req).await)
|
||||
}
|
||||
});
|
||||
@@ -705,7 +837,6 @@ pub async fn serve_webdav_loop(
|
||||
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
||||
|
||||
if let Err(err) = builder.serve_connection(io, service).await {
|
||||
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
|
||||
debug!("HTTP-Verbindungsende: {:?}", err);
|
||||
}
|
||||
});
|
||||
@@ -744,6 +875,74 @@ mod tests {
|
||||
assert_eq!(format_drive('s'), "S:");
|
||||
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_uri_contains_token_rejection() {
|
||||
let token = "deadbeefcafebabe0123456789abcdef";
|
||||
|
||||
// Token im Pfad
|
||||
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(uri_contains_token(&uri_path, token));
|
||||
|
||||
// Token im Query-String
|
||||
let uri_query: hyper::Uri =
|
||||
format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(uri_contains_token(&uri_query, token));
|
||||
|
||||
// Harmloser Pfad ohne Token
|
||||
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap();
|
||||
assert!(!uri_contains_token(&uri_clean, token));
|
||||
|
||||
// Leeres Token darf niemals matchen
|
||||
assert!(!uri_contains_token(&uri_clean, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+151
-46
@@ -6,8 +6,8 @@ use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::{
|
||||
derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
|
||||
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||
decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
|
||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
||||
|
||||
@@ -85,11 +85,31 @@ impl HeaderBackup {
|
||||
let mut slots = Vec::new();
|
||||
|
||||
if !self.slots.is_empty() {
|
||||
if self.slots.len() > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)",
|
||||
self.slots.len()
|
||||
);
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for s in &self.slots {
|
||||
let salt_bytes = hex::decode(&s.kdf_salt_hex)
|
||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
if s.slot_id > 1 {
|
||||
bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id);
|
||||
}
|
||||
if !seen_ids.insert(s.slot_id) {
|
||||
bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id);
|
||||
}
|
||||
crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| {
|
||||
anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id)
|
||||
})?;
|
||||
|
||||
let salt_bytes =
|
||||
hex::decode(&s.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
if salt_bytes.len() != 16 {
|
||||
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
|
||||
salt_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
kdf_salt.copy_from_slice(&salt_bytes);
|
||||
@@ -97,10 +117,24 @@ impl HeaderBackup {
|
||||
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
|
||||
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
|
||||
|
||||
if s.slot_id == 0 {
|
||||
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||
bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len());
|
||||
}
|
||||
} else if s.slot_id == 1 {
|
||||
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32
|
||||
{
|
||||
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
|
||||
}
|
||||
}
|
||||
|
||||
let nonce_bytes = hex::decode(&s.header_nonce_hex)
|
||||
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
||||
if nonce_bytes.len() != 12 {
|
||||
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
|
||||
nonce_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_bytes);
|
||||
@@ -108,7 +142,10 @@ impl HeaderBackup {
|
||||
let tag_bytes = hex::decode(&s.header_tag_hex)
|
||||
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
||||
if tag_bytes.len() != 16 {
|
||||
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
|
||||
tag_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
header_tag.copy_from_slice(&tag_bytes);
|
||||
@@ -123,12 +160,19 @@ impl HeaderBackup {
|
||||
header_tag,
|
||||
});
|
||||
}
|
||||
|
||||
if !seen_ids.contains(&0) {
|
||||
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||
}
|
||||
} else {
|
||||
// Fallback für alte Backups ohne slots-Array
|
||||
let salt_bytes = hex::decode(&self.kdf_salt_hex)
|
||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
let salt_bytes =
|
||||
hex::decode(&self.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
if salt_bytes.len() != 16 {
|
||||
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
|
||||
salt_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
kdf_salt.copy_from_slice(&salt_bytes);
|
||||
@@ -139,7 +183,10 @@ impl HeaderBackup {
|
||||
let nonce_bytes = hex::decode(&self.header_nonce_hex)
|
||||
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
||||
if nonce_bytes.len() != 12 {
|
||||
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
|
||||
nonce_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_bytes);
|
||||
@@ -147,7 +194,10 @@ impl HeaderBackup {
|
||||
let tag_bytes = hex::decode(&self.header_tag_hex)
|
||||
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
||||
if tag_bytes.len() != 16 {
|
||||
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
|
||||
bail!(
|
||||
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
|
||||
tag_bytes.len()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
header_tag.copy_from_slice(&tag_bytes);
|
||||
@@ -180,19 +230,28 @@ impl HeaderBackup {
|
||||
/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr).
|
||||
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
let backup = HeaderBackup::from_meta(&meta);
|
||||
let json_data = serde_json::to_string_pretty(&backup)
|
||||
.context("Fehler beim Serialisieren des Header-Backups")?;
|
||||
|
||||
fs::write(backup_path, json_data)
|
||||
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
|
||||
fs::write(backup_path, json_data).with_context(|| {
|
||||
format!(
|
||||
"Konnte Backup-Datei '{}' nicht schreiben",
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -203,25 +262,53 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
||||
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(backup_path)
|
||||
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
|
||||
let content = fs::read_to_string(backup_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte Backup-Datei '{}' nicht lesen",
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let backup: HeaderBackup = serde_json::from_str(&content)
|
||||
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
|
||||
|
||||
let meta = backup.to_meta()?;
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
|
||||
|
||||
db.restore_meta(&meta)
|
||||
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
|
||||
|
||||
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
|
||||
|
||||
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
|
||||
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
||||
pub fn restore_slot_from_recovery_key(
|
||||
@@ -229,18 +316,19 @@ pub fn restore_slot_from_recovery_key(
|
||||
recovery_key: &str,
|
||||
new_password: &str,
|
||||
target_slot_id: u32,
|
||||
dek_0: Option<&[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if new_password.trim().is_empty() {
|
||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
validate_password(new_password)?;
|
||||
|
||||
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
||||
let dek = mnemonic_to_dek(recovery_key)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
|
||||
// 2. Neuen KEK mit frischem Salt ableiten
|
||||
let mut salt = [0u8; 16];
|
||||
@@ -250,17 +338,18 @@ pub fn restore_slot_from_recovery_key(
|
||||
let kek = derive_kek(new_password, &salt, &kdf_params)
|
||||
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||
|
||||
let mut existing_slots = db.read_slots().unwrap_or_default();
|
||||
|
||||
let new_slot = if target_slot_id == 1 {
|
||||
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
|
||||
let dek_0_bytes = [0u8; 32];
|
||||
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
|
||||
// Für Slot 1 wird der echte DEK_0 benötigt (S-05)
|
||||
let dek_0_bytes = dek_0.ok_or_else(|| {
|
||||
anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
|
||||
})?;
|
||||
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
|
||||
SlotMeta {
|
||||
slot_id: 1,
|
||||
version: FORMAT_VERSION,
|
||||
@@ -289,7 +378,7 @@ pub fn restore_slot_from_recovery_key(
|
||||
existing_slots.push(new_slot);
|
||||
existing_slots.sort_by_key(|s| s.slot_id);
|
||||
|
||||
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
|
||||
// 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) {
|
||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||
let dummy_slot = SlotMeta {
|
||||
@@ -322,7 +411,8 @@ pub fn restore_slot_from_recovery_key(
|
||||
db.restore_meta(&meta)
|
||||
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
||||
|
||||
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -333,7 +423,7 @@ pub fn restore_header_from_recovery_key(
|
||||
recovery_key: &str,
|
||||
new_password: &str,
|
||||
) -> 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)]
|
||||
@@ -345,8 +435,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_header_backup_and_restore() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
|
||||
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
|
||||
let container_path: PathBuf =
|
||||
temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
|
||||
let backup_path: PathBuf =
|
||||
temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
@@ -358,8 +450,8 @@ mod tests {
|
||||
let password = "SuperSecretPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
@@ -367,7 +459,8 @@ mod tests {
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 1. Export
|
||||
@@ -391,7 +484,12 @@ mod tests {
|
||||
let restored_db = Database::open(&container_path).unwrap();
|
||||
let meta = restored_db.read_meta().expect("Read restored meta");
|
||||
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
|
||||
let active_dek = unwrap_dek(
|
||||
&restored_kek,
|
||||
&meta.wrapped_dek,
|
||||
&meta.header_nonce,
|
||||
&meta.header_tag,
|
||||
)
|
||||
.expect("Unwrap restored DEK");
|
||||
assert_eq!(*dek, *active_dek);
|
||||
|
||||
@@ -402,7 +500,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_restore_from_recovery_key() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
|
||||
let container_path: PathBuf =
|
||||
temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
@@ -413,8 +512,8 @@ mod tests {
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
||||
@@ -425,7 +524,8 @@ mod tests {
|
||||
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Header zerstören
|
||||
@@ -441,7 +541,12 @@ mod tests {
|
||||
let rescued_db = Database::open(&container_path).unwrap();
|
||||
let meta = rescued_db.read_meta().expect("Read rescued meta");
|
||||
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
|
||||
let unwrapped = unwrap_dek(
|
||||
&new_kek,
|
||||
&meta.wrapped_dek,
|
||||
&meta.header_nonce,
|
||||
&meta.header_tag,
|
||||
)
|
||||
.expect("Unwrap rescued DEK");
|
||||
assert_eq!(*dek, *unwrapped);
|
||||
|
||||
|
||||
+462
-119
@@ -9,7 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::crypto::{
|
||||
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;
|
||||
|
||||
@@ -93,12 +94,39 @@ impl ContainerMeta {
|
||||
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
|
||||
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
|
||||
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
|
||||
// SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek)
|
||||
// Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten
|
||||
if self.slots.is_empty() || self.slots.len() > 2 {
|
||||
return None;
|
||||
}
|
||||
if !self.slots.iter().any(|s| s.slot_id == 0) {
|
||||
return None;
|
||||
}
|
||||
if self.slots.iter().any(|s| s.slot_id > 1) {
|
||||
return None;
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for slot in &self.slots {
|
||||
if !seen_ids.insert(slot.slot_id) {
|
||||
return None;
|
||||
}
|
||||
if validate_kdf_params(&slot.kdf_params).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut matching = None;
|
||||
for slot in &self.slots {
|
||||
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
|
||||
.ok()
|
||||
.and_then(|kek| {
|
||||
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
|
||||
unwrap_key_payload(
|
||||
&kek,
|
||||
&slot.wrapped_dek,
|
||||
&slot.header_nonce,
|
||||
&slot.header_tag,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Some(payload) = res {
|
||||
@@ -108,7 +136,11 @@ impl ContainerMeta {
|
||||
let carrier_node_id = if payload.len() >= 40 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
|
||||
if cid > 0 { Some(cid) } else { None }
|
||||
if cid > 0 {
|
||||
Some(cid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if payload.len() >= 32 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
None
|
||||
@@ -134,7 +166,13 @@ impl ContainerMeta {
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
|
||||
matching = Some(UnlockedKeys(
|
||||
dek_1,
|
||||
slot.version,
|
||||
1,
|
||||
carrier_dek,
|
||||
carrier_node_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,11 +215,100 @@ impl Database {
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
// SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS)
|
||||
let has_meta_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if has_meta_table {
|
||||
let slot_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM meta;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
db.init_pragmas()?;
|
||||
if table_count > 0 {
|
||||
db.ensure_schema_upgrades()?;
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -204,20 +331,37 @@ impl Database {
|
||||
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
||||
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
||||
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<()> {
|
||||
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);
|
||||
if av != 2 {
|
||||
// Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum
|
||||
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
@@ -240,7 +384,9 @@ impl Database {
|
||||
/// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben.
|
||||
pub fn incremental_vacuum(&self, pages: Option<usize>) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let before: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if before <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -259,7 +405,9 @@ impl Database {
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
|
||||
let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let after: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let actual_freed = (before - after).max(0) as usize;
|
||||
Ok(actual_freed.max(stepped))
|
||||
}
|
||||
@@ -271,16 +419,21 @@ impl Database {
|
||||
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>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
||||
)?;
|
||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?;
|
||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||
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 {
|
||||
@@ -295,16 +448,26 @@ impl Database {
|
||||
)
|
||||
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()?;
|
||||
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).
|
||||
///
|
||||
/// SA-07 / Technischer Hinweis zu sicherem Löschen:
|
||||
/// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records
|
||||
/// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor
|
||||
/// logischer Wiederherstellung auf Dateisystem- und Datenbankebene.
|
||||
/// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS)
|
||||
/// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization)
|
||||
/// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren
|
||||
/// neuen Flash-Speicherzellen zuweisen.
|
||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1"
|
||||
)?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
||||
let chunks: Vec<(u32, usize)> = stmt
|
||||
.query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
@@ -374,6 +537,7 @@ impl Database {
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_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
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||
@@ -430,10 +594,10 @@ impl Database {
|
||||
dek_1,
|
||||
)) = 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(
|
||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
||||
VALUES (1, ?1, 0, ?2, ?3, ?4)",
|
||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
||||
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
|
||||
params![c_name, c_size as i64, now, now],
|
||||
)?;
|
||||
let c_id = conn.last_insert_rowid();
|
||||
@@ -461,13 +625,8 @@ impl Database {
|
||||
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
||||
|
||||
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_1,
|
||||
c_id,
|
||||
0,
|
||||
&manifest_bytes,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (inner_ct, inner_nonce, inner_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?;
|
||||
let inner_ct_len = inner_ct.len() as u32;
|
||||
|
||||
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
||||
@@ -482,13 +641,8 @@ impl Database {
|
||||
}
|
||||
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
||||
|
||||
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
0,
|
||||
&outer_plaintext,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (outer_ct, outer_nonce, outer_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
||||
@@ -507,20 +661,9 @@ impl Database {
|
||||
|
||||
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||
for b in 1..total_blocks {
|
||||
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
b,
|
||||
&dummy_noise,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
chunk_stmt.execute(params![
|
||||
c_id,
|
||||
b,
|
||||
nonce.as_slice(),
|
||||
tag.as_slice(),
|
||||
ct,
|
||||
])?;
|
||||
let (ct, nonce, tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?;
|
||||
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
|
||||
|
||||
if b % 500 == 0 {
|
||||
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
|
||||
@@ -553,7 +696,7 @@ impl Database {
|
||||
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:
|
||||
/// - 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)
|
||||
@@ -589,6 +732,7 @@ impl Database {
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_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
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||
@@ -633,7 +777,7 @@ impl Database {
|
||||
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 {
|
||||
let params_json_1 = serde_json::to_string(h_params)?;
|
||||
conn.execute(
|
||||
@@ -671,7 +815,7 @@ impl Database {
|
||||
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(
|
||||
&self,
|
||||
salt: &[u8; 16],
|
||||
@@ -680,18 +824,43 @@ impl Database {
|
||||
header_nonce: &[u8; 12],
|
||||
header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None)
|
||||
self.init_schema_with_hidden(
|
||||
salt,
|
||||
kdf_params,
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen).
|
||||
/// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch.
|
||||
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS)
|
||||
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?;
|
||||
if slot_count == 0 {
|
||||
bail!("Container-Header ist leer oder beschädigt");
|
||||
}
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag
|
||||
FROM meta ORDER BY slot_id ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut slots = Vec::new();
|
||||
let mut seen_slot_ids = std::collections::HashSet::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slot_id: u32 = row.get(0)?;
|
||||
let version: u32 = row.get(1)?;
|
||||
let salt_vec: Vec<u8> = row.get(2)?;
|
||||
@@ -700,22 +869,69 @@ impl Database {
|
||||
let nonce_vec: Vec<u8> = row.get(5)?;
|
||||
let tag_vec: Vec<u8> = row.get(6)?;
|
||||
|
||||
// SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig
|
||||
if slot_id > 1 {
|
||||
bail!(
|
||||
"Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
if !seen_slot_ids.insert(slot_id) {
|
||||
bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id);
|
||||
}
|
||||
|
||||
if salt_vec.len() != 16 {
|
||||
bail!(
|
||||
"Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
salt_vec.len()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
if salt_vec.len() == 16 {
|
||||
kdf_salt.copy_from_slice(&salt_vec);
|
||||
|
||||
if nonce_vec.len() != 12 {
|
||||
bail!(
|
||||
"Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)",
|
||||
slot_id,
|
||||
nonce_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
if nonce_vec.len() == 12 {
|
||||
header_nonce.copy_from_slice(&nonce_vec);
|
||||
|
||||
if tag_vec.len() != 16 {
|
||||
bail!(
|
||||
"Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
tag_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
if tag_vec.len() == 16 {
|
||||
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 {
|
||||
bail!(
|
||||
"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 {
|
||||
bail!("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| {
|
||||
anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id)
|
||||
})?;
|
||||
|
||||
Ok(SlotMeta {
|
||||
validate_kdf_params(&kdf_params)
|
||||
.map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?;
|
||||
|
||||
slots.push(SlotMeta {
|
||||
slot_id,
|
||||
version,
|
||||
kdf_salt,
|
||||
@@ -723,13 +939,14 @@ impl Database {
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut slots = Vec::new();
|
||||
for r in rows {
|
||||
slots.push(r?);
|
||||
});
|
||||
}
|
||||
|
||||
// SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich
|
||||
if !seen_slot_ids.contains(&0) {
|
||||
bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||
}
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
@@ -753,7 +970,10 @@ impl Database {
|
||||
}
|
||||
|
||||
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
|
||||
bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version);
|
||||
bail!(
|
||||
"Nicht unterstützte Sanctum-Formatversion: {}",
|
||||
slot0.version
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ContainerMeta {
|
||||
@@ -776,7 +996,14 @@ impl Database {
|
||||
new_header_nonce: &[u8; 12],
|
||||
new_header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag)
|
||||
self.update_slot_keys(
|
||||
0,
|
||||
new_salt,
|
||||
new_params,
|
||||
new_wrapped_dek,
|
||||
new_header_nonce,
|
||||
new_header_tag,
|
||||
)
|
||||
}
|
||||
|
||||
/// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots.
|
||||
@@ -804,7 +1031,10 @@ impl Database {
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id);
|
||||
bail!(
|
||||
"Konnte Container-Header für Slot {} nicht aktualisieren",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -903,7 +1133,8 @@ impl Database {
|
||||
let mut matched_record = None;
|
||||
for r in rows {
|
||||
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
||||
let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
let dec_name =
|
||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
if dec_name == *segment {
|
||||
matched_record = Some(NodeRecord {
|
||||
id,
|
||||
@@ -1035,6 +1266,7 @@ impl Database {
|
||||
is_dir: bool,
|
||||
dek: &[u8; 32],
|
||||
) -> Result<NodeRecord> {
|
||||
crate::pathutil::validate_node_name(name)?;
|
||||
let now = current_timestamp();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
@@ -1083,8 +1315,32 @@ impl Database {
|
||||
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.
|
||||
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
|
||||
let _ = self.shred_chunks_for_node(id);
|
||||
|
||||
@@ -1118,6 +1374,8 @@ impl Database {
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
self.assert_not_carrier(id)?;
|
||||
crate::pathutil::validate_node_name(new_name)?;
|
||||
let now = current_timestamp();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let stored_name = if vault_id == 1 {
|
||||
@@ -1212,6 +1470,7 @@ impl Database {
|
||||
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(
|
||||
@@ -1240,6 +1499,7 @@ impl Database {
|
||||
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
||||
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
||||
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 tx = conn.transaction()?;
|
||||
{
|
||||
@@ -1286,11 +1546,10 @@ impl Database {
|
||||
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let _res: (i64, i64, i64) = conn.query_row(
|
||||
"PRAGMA wal_checkpoint(TRUNCATE);",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
let _res: (i64, i64, i64) =
|
||||
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1407,7 +1666,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 {
|
||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
||||
@@ -1465,8 +1724,7 @@ impl Database {
|
||||
/// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container.
|
||||
pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let dirs: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
|
||||
let dirs: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let files: i64 =
|
||||
@@ -1532,7 +1790,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Meta abrufen
|
||||
let meta = db.read_meta().unwrap();
|
||||
@@ -1555,19 +1814,29 @@ mod tests {
|
||||
assert!(!file.is_dir);
|
||||
|
||||
// Pfadauflösung testen
|
||||
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
|
||||
let resolved_file = db
|
||||
.resolve_path("/documents/notes.txt")
|
||||
.unwrap()
|
||||
.expect("File should resolve");
|
||||
assert_eq!(resolved_file.id, file.id);
|
||||
|
||||
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
|
||||
let resolved_docs = db
|
||||
.resolve_path("documents")
|
||||
.unwrap()
|
||||
.expect("Docs should resolve");
|
||||
assert_eq!(resolved_docs.id, docs.id);
|
||||
|
||||
// Chunks schreiben & lesen
|
||||
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
|
||||
let c_nonce = [7u8; 12];
|
||||
let c_tag = [8u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher)
|
||||
.unwrap();
|
||||
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk 0 should exist");
|
||||
assert_eq!(chunk.ciphertext, test_cipher);
|
||||
|
||||
// Truncate
|
||||
@@ -1597,7 +1866,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let root = db.resolve_path("/").unwrap().expect("Root node");
|
||||
let file = db.create_node(root.id, "large_file.bin", false).unwrap();
|
||||
@@ -1607,7 +1877,8 @@ mod tests {
|
||||
let c_nonce = [5u8; 12];
|
||||
let c_tag = [6u8; 16];
|
||||
for i in 0..20 {
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap();
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
@@ -1616,12 +1887,18 @@ mod tests {
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let freelist_before = db.freelist_count().unwrap();
|
||||
assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten");
|
||||
assert!(
|
||||
freelist_before > 0,
|
||||
"Freelist sollte nach dem Löschen freie Seiten enthalten"
|
||||
);
|
||||
|
||||
// Incremental Vacuum ausführen
|
||||
let freed = db.incremental_vacuum(None).unwrap();
|
||||
assert!(freed > 0, "Es sollten Seiten freigegeben werden");
|
||||
assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden");
|
||||
assert_eq!(
|
||||
freed, freelist_before,
|
||||
"Alle freien Seiten müssen freigegeben werden"
|
||||
);
|
||||
|
||||
let freelist_after = db.freelist_count().unwrap();
|
||||
assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein");
|
||||
@@ -1638,22 +1915,34 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
let root = db.resolve_path("/").unwrap().expect("Root");
|
||||
let file = db.create_node(root.id, "sensitive.dat", false).unwrap();
|
||||
|
||||
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
|
||||
let c_nonce = [10u8; 12];
|
||||
let c_tag = [11u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload)
|
||||
.unwrap();
|
||||
|
||||
// Shredde Chunks
|
||||
db.shred_chunks_for_node(file.id).unwrap();
|
||||
|
||||
// Prüfe, was sich in der Chunks-Tabelle befindet
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch");
|
||||
assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!");
|
||||
assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk existiert noch");
|
||||
assert_ne!(
|
||||
chunk.ciphertext, sensitive_payload,
|
||||
"Ciphertext muss überschrieben sein!"
|
||||
);
|
||||
assert_eq!(
|
||||
chunk.ciphertext.len(),
|
||||
sensitive_payload.len(),
|
||||
"Länge muss identisch sein"
|
||||
);
|
||||
assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein");
|
||||
assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein");
|
||||
}
|
||||
@@ -1677,9 +1966,14 @@ mod tests {
|
||||
let dek1 = [0xBBu8; 32];
|
||||
|
||||
db.init_schema_with_hidden(
|
||||
&salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0,
|
||||
&salt0,
|
||||
&kdf_params0,
|
||||
&wrapped_dek0,
|
||||
&nonce0,
|
||||
&tag0,
|
||||
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Slots prüfen
|
||||
let slots = db.read_slots().unwrap();
|
||||
@@ -1688,63 +1982,107 @@ mod tests {
|
||||
assert_eq!(slots[1].slot_id, 1);
|
||||
|
||||
// Datei in Vault 0 (Decoy) erstellen
|
||||
let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0");
|
||||
let root0 = db
|
||||
.resolve_path_in_vault("/", 0, &dek0)
|
||||
.unwrap()
|
||||
.expect("Root 0");
|
||||
assert_eq!(root0.id, 1);
|
||||
let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap();
|
||||
let decoy_file = db
|
||||
.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0)
|
||||
.unwrap();
|
||||
|
||||
// Datei in Vault 1 (Hidden) erstellen
|
||||
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1");
|
||||
let root1 = db
|
||||
.resolve_path_in_vault("/", 1, &dek1)
|
||||
.unwrap()
|
||||
.expect("Root 1");
|
||||
assert_eq!(root1.id, 2);
|
||||
let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap();
|
||||
let hidden_file = db
|
||||
.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1)
|
||||
.unwrap();
|
||||
|
||||
// Auflösen in Vault 0: Sieht nur public_recipe.txt
|
||||
let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap();
|
||||
let res_decoy = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(res_decoy.is_some());
|
||||
assert_eq!(res_decoy.unwrap().id, decoy_file.id);
|
||||
|
||||
let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap();
|
||||
assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!");
|
||||
let res_hidden_in_v0 = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_hidden_in_v0.is_none(),
|
||||
"Vault 0 darf keine Dateien aus Hidden Vault auflösen!"
|
||||
);
|
||||
|
||||
// Auflösen in Vault 1: Sieht nur classified_leak.pdf
|
||||
let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap();
|
||||
let res_hidden = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(res_hidden.is_some());
|
||||
assert_eq!(res_hidden.unwrap().id, hidden_file.id);
|
||||
|
||||
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
|
||||
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
|
||||
let res_decoy_in_v1 = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_decoy_in_v1.is_none(),
|
||||
"Vault 1 darf keine Dateien aus Vault 0 auflösen!"
|
||||
);
|
||||
|
||||
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
|
||||
let conn = db.conn.lock().unwrap();
|
||||
let raw_name_v0: String = conn.query_row(
|
||||
let raw_name_v0: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![decoy_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(raw_name_v0, "public_recipe.txt");
|
||||
|
||||
let raw_name_v1: String = conn.query_row(
|
||||
let raw_name_v1: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![hidden_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
// Plausible Deniability: 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.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
||||
)
|
||||
.unwrap();
|
||||
// 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.contains("classified_leak"),
|
||||
"Plaintext darf keinesfalls in SQLite DB auftauchen"
|
||||
);
|
||||
|
||||
// Keine Spalte `vault_id` in nodes oder chunks
|
||||
let has_vault_id_nodes: i64 = conn.query_row(
|
||||
let has_vault_id_nodes: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_nodes, 0,
|
||||
"vault_id darf nicht in nodes existieren"
|
||||
);
|
||||
|
||||
let has_vault_id_chunks: i64 = conn.query_row(
|
||||
let has_vault_id_chunks: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_chunks, 0,
|
||||
"vault_id darf nicht in chunks existieren"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1757,7 +2095,8 @@ mod tests {
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
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);
|
||||
@@ -1776,10 +2115,14 @@ mod tests {
|
||||
&ciphertext,
|
||||
new_size,
|
||||
modified_at,
|
||||
).expect("Atomic write");
|
||||
)
|
||||
.expect("Atomic write");
|
||||
|
||||
// Chunk verifizieren
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
|
||||
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);
|
||||
|
||||
+1083
File diff suppressed because it is too large
Load Diff
@@ -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...`
|
||||
pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
||||
let tag = cyan(&format!("[{}/{}]", num, total));
|
||||
@@ -110,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
||||
pub fn print_recovery_phrase_card(phrase: &str) {
|
||||
let words: Vec<&str> = phrase.split_whitespace().collect();
|
||||
println!();
|
||||
println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐"));
|
||||
println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │"));
|
||||
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
|
||||
println!(
|
||||
"{}",
|
||||
yellow("┌─────────────────────────────────────────────────────────────┐")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
yellow("├─────────────────────────────────────────────────────────────┤")
|
||||
);
|
||||
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
|
||||
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
|
||||
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
|
||||
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
|
||||
println!(
|
||||
"{}",
|
||||
yellow("├─────────────────────────────────────────────────────────────┤")
|
||||
);
|
||||
|
||||
for row in 0..8 {
|
||||
let w1 = if row < words.len() {
|
||||
@@ -137,7 +170,10 @@ pub fn print_recovery_phrase_card(phrase: &str) {
|
||||
println!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3));
|
||||
}
|
||||
|
||||
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
|
||||
println!(
|
||||
"{}",
|
||||
yellow("└─────────────────────────────────────────────────────────────┘")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -183,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
||||
println!(" - Daten-Chunks: {}", report.total_chunks);
|
||||
if report.total_bytes_decrypted > 0 {
|
||||
let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0);
|
||||
println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb);
|
||||
println!(
|
||||
" - Verifiziert: {:.2} MB (vollständig entschlüsselt)",
|
||||
mb
|
||||
);
|
||||
}
|
||||
|
||||
if !report.errors.is_empty() {
|
||||
@@ -196,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
||||
|
||||
println!();
|
||||
if report.is_healthy() {
|
||||
println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer."));
|
||||
println!(
|
||||
" {}",
|
||||
green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.")
|
||||
);
|
||||
} else {
|
||||
println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen."));
|
||||
println!(
|
||||
" {}",
|
||||
red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ui;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum";
|
||||
|
||||
pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
|
||||
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
|
||||
|
||||
/// Repräsentiert ein Asset in einem Gitea-Release
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ReleaseAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Repräsentiert ein Release von der Gitea Releases-API
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct GiteaRelease {
|
||||
pub id: u64,
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub html_url: String,
|
||||
pub published_at: Option<String>,
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
/// Optionen für das Upgrade
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpgradeOptions {
|
||||
pub check_only: bool,
|
||||
pub yes: bool,
|
||||
pub force: bool,
|
||||
pub base_url: String,
|
||||
pub insecure_url: bool,
|
||||
}
|
||||
|
||||
impl Default for UpgradeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_only: false,
|
||||
yes: false,
|
||||
force: false,
|
||||
base_url: OFFICIAL_UPGRADE_URL.to_string(),
|
||||
insecure_url: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
|
||||
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<()> {
|
||||
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
||||
let signature = minisign_verify::Signature::decode(sig_str)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
||||
pubkey
|
||||
.verify(data, &signature, true)
|
||||
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erkannter Paketmanager
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PackageManager {
|
||||
Scoop,
|
||||
Winget,
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "Scoop",
|
||||
PackageManager::Winget => "Winget",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade_command(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "scoop update sanctum",
|
||||
PackageManager::Winget => "winget upgrade HaraldPansi.Sanctum",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft, ob die laufende Binary durch einen Paketmanager (Scoop oder Winget) verwaltet wird.
|
||||
pub fn detect_package_manager() -> Option<PackageManager> {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
detect_package_manager_from_path(&exe_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion für die Paketmanager-Erkennung anhand eines Pfads (für Tests isolierbar).
|
||||
pub fn detect_package_manager_from_path(path: &Path) -> Option<PackageManager> {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
if s.contains("scoop\\apps\\sanctum")
|
||||
|| s.contains("scoop/apps/sanctum")
|
||||
|| s.contains("scoop\\shims")
|
||||
|| s.contains("scoop/shims")
|
||||
{
|
||||
return Some(PackageManager::Scoop);
|
||||
}
|
||||
if s.contains("winget\\packages")
|
||||
|| s.contains("winget/packages")
|
||||
|| s.contains("winget\\links")
|
||||
|| s.contains("winget/links")
|
||||
{
|
||||
return Some(PackageManager::Winget);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ruft das neueste Release von der Gitea Releases-API ab.
|
||||
pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
|
||||
|
||||
let resp = ureq::get(&api_url)
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.set("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Netzwerkfehler beim Abrufen der Releases von '{}'", api_url))?;
|
||||
|
||||
let release: GiteaRelease = resp
|
||||
.into_json()
|
||||
.context("Ungültiges JSON-Format von der Gitea Releases-API empfangen")?;
|
||||
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// Lädt eine Datei von einer URL als Text herunter.
|
||||
pub fn fetch_text(url: &str) -> Result<String> {
|
||||
let resp = ureq::get(url)
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
|
||||
|
||||
let text = resp
|
||||
.into_string()
|
||||
.with_context(|| format!("Fehler beim Lesen des Texts von '{}'", url))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Extrahiert die SHA-256 Prüfsumme für ein bestimmtes Asset aus dem Inhalt von `SHA256SUMS.txt`.
|
||||
pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) -> Option<String> {
|
||||
for line in sha256sums_content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let hash = parts[0];
|
||||
let filename = parts[1].trim_start_matches('*');
|
||||
if filename.eq_ignore_ascii_case(target_asset) && hash.len() == 64 {
|
||||
return Some(hash.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
|
||||
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
|
||||
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
|
||||
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
|
||||
}
|
||||
|
||||
/// Vergleicht die aktuelle Version mit der entfernten Version.
|
||||
/// Gibt `true` zurück, wenn `remote > current`.
|
||||
pub fn is_newer_version(current: &str, remote: &str) -> bool {
|
||||
let cur = match parse_clean_version(current) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let rem = match parse_clean_version(remote) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rem > cur
|
||||
}
|
||||
|
||||
/// Lädt das Ziel-Binary in eine temporäre Datei herunter und validiert dabei den SHA-256 Hash.
|
||||
pub fn download_and_verify_binary(
|
||||
download_url: &str,
|
||||
expected_hash: &str,
|
||||
expected_size: u64,
|
||||
) -> Result<PathBuf> {
|
||||
let resp = ureq::get(download_url)
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
|
||||
let mut temp_file = tempfile::Builder::new()
|
||||
.prefix("sanctum_update_")
|
||||
.suffix(if cfg!(windows) { ".exe" } else { "" })
|
||||
.tempfile()
|
||||
.context("Konnte temporäre Update-Datei nicht sicher erstellen")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
|
||||
let mut downloaded_bytes = 0u64;
|
||||
|
||||
println!(" • Herunterladen von: {}", download_url);
|
||||
print!(" • Fortschritt: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.context("Fehler beim Lesen des Download-Datenstroms")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
temp_file
|
||||
.as_file_mut()
|
||||
.write_all(&buffer[..n])
|
||||
.context("Fehler beim Schreiben in die temporäre Datei")?;
|
||||
hasher.update(&buffer[..n]);
|
||||
|
||||
downloaded_bytes += n as u64;
|
||||
|
||||
if expected_size > 0 {
|
||||
let percent = (downloaded_bytes as f64 / expected_size as f64 * 100.0).min(100.0);
|
||||
print!(
|
||||
"\r • Fortschritt: {} / {} ({:.1}%)",
|
||||
ui::format_bytes(downloaded_bytes),
|
||||
ui::format_bytes(expected_size),
|
||||
percent
|
||||
);
|
||||
} else {
|
||||
print!(
|
||||
"\r • Fortschritt: {}",
|
||||
ui::format_bytes(downloaded_bytes)
|
||||
);
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
println!();
|
||||
|
||||
temp_file
|
||||
.as_file_mut()
|
||||
.flush()
|
||||
.context("Fehler beim Flush der temporären Datei")?;
|
||||
|
||||
// SHA-256 Integritätsprüfung
|
||||
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
|
||||
let expected_lower = expected_hash.to_lowercase();
|
||||
|
||||
if calculated_hash != expected_lower {
|
||||
// temp_file wird beim Drop automatisch gelöscht
|
||||
bail!(
|
||||
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
|
||||
expected_lower,
|
||||
calculated_hash
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
" • SHA-256: {} (verifiziert)",
|
||||
ui::green(&calculated_hash[..16])
|
||||
);
|
||||
|
||||
let (_, path) = temp_file
|
||||
.keep()
|
||||
.context("Konnte temporäre Datei nicht für Ersetzung persistieren")?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Führt den In-Place-Austausch des laufenden Binaries durch.
|
||||
pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Konnte den Pfad der aktuell ausgeführten Datei nicht ermitteln")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(temp_binary) {
|
||||
let mut perms = meta.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let _ = fs::set_permissions(temp_binary, perms);
|
||||
}
|
||||
}
|
||||
|
||||
let replace_res = self_replace::self_replace(temp_binary);
|
||||
let _ = fs::remove_file(temp_binary);
|
||||
|
||||
replace_res.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
|
||||
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Führt den gesamten Upgrade-Workflow aus.
|
||||
pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(
|
||||
" • Aktuelle Version: {}",
|
||||
ui::cyan(&format!("v{}", current_version))
|
||||
);
|
||||
println!(" • Server: {}", options.base_url);
|
||||
println!();
|
||||
|
||||
// S-02: Prüfung auf abweichende Server-URL
|
||||
let is_official_server =
|
||||
options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
|
||||
if !is_official_server {
|
||||
if !options.insecure_url {
|
||||
bail!(
|
||||
"Sicherheitsverstoß: Abweichende Server-URL '{}' ist nicht vertrauenswürdig.\n\
|
||||
Aus Sicherheitsgründen sind Drittanbieter-URLs standardmäßig gesperrt.\n\
|
||||
Falls beabsichtigt, verwenden Sie das Flag '--insecure-url'.",
|
||||
options.base_url
|
||||
);
|
||||
}
|
||||
|
||||
// Auch mit --insecure-url ist zwingend eine manuelle Bestätigung erforderlich (nicht mit -y überschreibbar!)
|
||||
println!(
|
||||
" {} WARNUNG: Sie aktualisieren von einem inoffiziellen Server: {}",
|
||||
ui::red("[SICHERHEITSWARNUNG]"),
|
||||
options.base_url
|
||||
);
|
||||
println!(" Offizieller Server ist: {}", OFFICIAL_UPGRADE_URL);
|
||||
print!(" Möchten Sie dieses ungesicherte Update WIRKLICH fortsetzen? [Tippen Sie 'JA' zur Bestätigung]: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut confirm = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut confirm)
|
||||
.context("Fehler beim Einlesen der Bestätigung")?;
|
||||
if confirm.trim() != "JA" {
|
||||
bail!("Upgrade von Drittanbieter-URL durch Benutzer abgebrochen.");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// 1. Paketmanager-Prüfung
|
||||
if let Some(pm) = detect_package_manager() {
|
||||
println!(
|
||||
" {} Sanctum wird anscheinend über {} verwaltet!",
|
||||
ui::yellow("[!]"),
|
||||
pm.name()
|
||||
);
|
||||
println!(
|
||||
" Empfohlener Aktualisierungsbefehl: {}",
|
||||
ui::cyan(pm.upgrade_command())
|
||||
);
|
||||
println!();
|
||||
|
||||
if !options.force && !options.yes && !options.check_only {
|
||||
print!(" Möchten Sie das direkte In-Place-Upgrade trotzdem fortsetzen? [j/N]: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed != "j" && trimmed != "ja" && trimmed != "y" && trimmed != "yes" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Neuestes Release abfragen
|
||||
println!(
|
||||
" {} Suche nach neuesten Releases auf Gitea ...",
|
||||
ui::dim("[-]")
|
||||
);
|
||||
let release = fetch_latest_release(&options.base_url)?;
|
||||
let remote_tag = &release.tag_name;
|
||||
let is_newer = is_newer_version(current_version, remote_tag);
|
||||
|
||||
println!(
|
||||
" • Neueste Version: {}",
|
||||
if is_newer {
|
||||
ui::green(remote_tag)
|
||||
} else {
|
||||
ui::cyan(remote_tag)
|
||||
}
|
||||
);
|
||||
println!(" • Release-Name: {}", release.name);
|
||||
if let Some(ref pub_at) = release.published_at {
|
||||
println!(" • Veröffentlicht: {}", pub_at);
|
||||
}
|
||||
println!(" • Release-URL: {}", release.html_url);
|
||||
println!();
|
||||
|
||||
if !is_newer && !options.force {
|
||||
println!(
|
||||
" {} Sie verwenden bereits die neueste Version von Sanctum.",
|
||||
ui::green("✔")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if options.check_only {
|
||||
if is_newer {
|
||||
println!(
|
||||
" {} Ein Update auf {} ist verfügbar! Führen Sie 'sanctum upgrade' aus.",
|
||||
ui::yellow("[!]"),
|
||||
remote_tag
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Ziel-Asset, Checksumme und Minisign-Signatur identifizieren
|
||||
let target_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(TARGET_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde kein passendes Binary für Ihr Betriebssystem ('{}') gefunden!",
|
||||
remote_tag,
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde keine Prüfsummendatei ('{}') gefunden!",
|
||||
remote_tag,
|
||||
CHECKSUM_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
let sig_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_SIG_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde keine Minisign-Signatur ('{}') gefunden! Unsignierte Updates werden verweigert.",
|
||||
remote_tag,
|
||||
CHECKSUM_SIG_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
|
||||
println!(
|
||||
" {} Lade Prüfsummen ({}) herunter ...",
|
||||
ui::dim("[-]"),
|
||||
CHECKSUM_ASSET_NAME
|
||||
);
|
||||
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
|
||||
|
||||
println!(
|
||||
" {} Lade Minisign-Signatur ({}) herunter ...",
|
||||
ui::dim("[-]"),
|
||||
CHECKSUM_SIG_ASSET_NAME
|
||||
);
|
||||
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
|
||||
|
||||
println!(
|
||||
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
|
||||
ui::dim("[-]")
|
||||
);
|
||||
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
|
||||
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
|
||||
println!(
|
||||
" • Signatur: {} (Minisign Ed25519)",
|
||||
ui::green("Gültig")
|
||||
);
|
||||
|
||||
let expected_hash =
|
||||
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
||||
if !options.yes {
|
||||
println!(
|
||||
" • Ziel-Binary: {} ({})",
|
||||
target_asset.name,
|
||||
ui::format_bytes(target_asset.size)
|
||||
);
|
||||
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
|
||||
println!();
|
||||
print!(
|
||||
" Möchten Sie Sanctum jetzt von v{} auf {} aktualisieren? [J/n]: ",
|
||||
current_version, remote_tag
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed == "n" || trimmed == "nein" || trimmed == "no" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// 6. Download & Verifikation
|
||||
println!(" {} Lade neues Binary herunter ...", ui::dim("[-]"));
|
||||
let temp_file = download_and_verify_binary(
|
||||
&target_asset.browser_download_url,
|
||||
&expected_hash,
|
||||
target_asset.size,
|
||||
)?;
|
||||
|
||||
// 7. In-Place-Austausch
|
||||
println!(" {} Führe In-Place-Austausch durch ...", ui::dim("[-]"));
|
||||
let updated_exe = apply_upgrade(&temp_file)?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!(
|
||||
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
|
||||
remote_tag
|
||||
);
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Pfad: {}", updated_exe.display());
|
||||
println!(" • Neue Version: {}", ui::green(remote_tag));
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+67
-26
@@ -43,11 +43,13 @@ pub fn verify_container(
|
||||
full_chunks: bool,
|
||||
) -> Result<VerificationReport> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let mut report = VerificationReport {
|
||||
container_path: container_path.display().to_string(),
|
||||
@@ -67,7 +69,8 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 1. SQLite B-Tree & Foreign Key Prüfung
|
||||
let sqlite_issues = db.run_sqlite_integrity_check()
|
||||
let sqlite_issues = db
|
||||
.run_sqlite_integrity_check()
|
||||
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
|
||||
if !sqlite_issues.is_empty() {
|
||||
report.sqlite_ok = false;
|
||||
@@ -96,13 +99,16 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 3. Node-Hierarchie & Strukturprüfung
|
||||
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
|
||||
let (dirs, files, chunks_count) = db
|
||||
.count_nodes_and_chunks()
|
||||
.context("Fehler beim Zählen der Knoten und Chunks")?;
|
||||
report.total_dirs = dirs;
|
||||
report.total_files = files;
|
||||
report.total_chunks = chunks_count;
|
||||
|
||||
let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?;
|
||||
let all_nodes = db
|
||||
.list_all_nodes()
|
||||
.context("Fehler beim Laden der Knotenliste")?;
|
||||
report.total_nodes = all_nodes.len();
|
||||
|
||||
let mut node_map = HashMap::new();
|
||||
@@ -114,23 +120,33 @@ pub fn verify_container(
|
||||
match node_map.get(&1) {
|
||||
Some(root) => {
|
||||
if !root.is_dir {
|
||||
report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
|
||||
}
|
||||
if root.parent_id.is_some() {
|
||||
report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(root2) = node_map.get(&2) {
|
||||
if !root2.is_dir {
|
||||
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
|
||||
}
|
||||
if root2.parent_id.is_some() {
|
||||
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +248,14 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
|
||||
let chunk_headers = db.list_all_chunk_headers()
|
||||
let chunk_headers = db
|
||||
.list_all_chunk_headers()
|
||||
.context("Fehler beim Abrufen der Chunk-Liste")?;
|
||||
|
||||
let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2);
|
||||
let format_version = meta
|
||||
.as_ref()
|
||||
.map(|m| m.version)
|
||||
.unwrap_or(FORMAT_VERSION_V2);
|
||||
|
||||
for (node_id, chunk_index) in chunk_headers {
|
||||
if !node_map.contains_key(&node_id) {
|
||||
@@ -310,8 +330,8 @@ mod tests {
|
||||
let password = "HealthyTestPassword123!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
@@ -319,7 +339,8 @@ mod tests {
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Verzeichnis & Datei anlegen
|
||||
let folder = db.create_node(1, "photos", true).unwrap();
|
||||
@@ -334,14 +355,23 @@ mod tests {
|
||||
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
|
||||
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
|
||||
|
||||
db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap();
|
||||
db.update_node_size_and_time(
|
||||
file.id,
|
||||
(chunk0_data.len() + chunk1_data.len()) as u64,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verifizieren
|
||||
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_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.corrupted_chunks, 0);
|
||||
assert_eq!(report.orphan_nodes, 0);
|
||||
@@ -362,8 +392,8 @@ mod tests {
|
||||
let password = "BitrotTestPassword123!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
@@ -371,7 +401,8 @@ mod tests {
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let file = db.create_node(1, "document.pdf", false).unwrap();
|
||||
let chunk_data = b"Vital documents that must not be corrupted";
|
||||
@@ -387,14 +418,24 @@ mod tests {
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||
rusqlite::params![corrupted_ct, file.id],
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
|
||||
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
||||
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
|
||||
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
|
||||
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
|
||||
assert!(
|
||||
!report.is_healthy(),
|
||||
"Container must report unhealthy due to bitrot"
|
||||
);
|
||||
assert_eq!(
|
||||
report.corrupted_chunks, 1,
|
||||
"Must detect exactly 1 corrupted chunk"
|
||||
);
|
||||
assert!(report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("AEAD/Integritätsfehler")));
|
||||
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
+82
-62
@@ -26,14 +26,8 @@ use crate::storage::{Database, NodeRecord};
|
||||
pub fn is_leak_file(filename: &str) -> bool {
|
||||
let lower = filename.trim().to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"thumbs.db"
|
||||
| "ehthumbs.db"
|
||||
| "ehthumbs_vista.db"
|
||||
| "desktop.ini"
|
||||
| "folder.jpg"
|
||||
| "albumartsmall.jpg"
|
||||
| "autorun.inf"
|
||||
| ".ds_store" => true,
|
||||
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
|
||||
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
|
||||
_ => {
|
||||
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
|
||||
{
|
||||
@@ -167,7 +161,6 @@ impl SanctumFile {
|
||||
self.last_activity.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
|
||||
/// 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).
|
||||
@@ -179,10 +172,12 @@ impl SanctumFile {
|
||||
|
||||
if let Some((idx, ref data, true)) = self.cached_chunk {
|
||||
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| {
|
||||
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
},
|
||||
)?;
|
||||
|
||||
if let Err(e) = self.db.write_chunk_and_update_size(
|
||||
self.node_id,
|
||||
@@ -207,7 +202,10 @@ impl SanctumFile {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
@@ -263,7 +261,10 @@ impl SanctumFile {
|
||||
impl Drop for SanctumFile {
|
||||
fn drop(&mut self) {
|
||||
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 {
|
||||
data.zeroize();
|
||||
@@ -354,7 +355,12 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
// 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_and_size()?;
|
||||
}
|
||||
|
||||
@@ -562,6 +568,7 @@ impl SanctumFs {
|
||||
|
||||
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
|
||||
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
||||
.map_err(|e| {
|
||||
@@ -572,6 +579,7 @@ impl SanctumFs {
|
||||
|
||||
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
|
||||
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
@@ -631,14 +639,19 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
self.touch();
|
||||
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
|
||||
debug!(
|
||||
"VFS open aufgerufen: path='{}', options={:?}",
|
||||
path_str, options
|
||||
);
|
||||
|
||||
let existing_node = self.resolve_path(&path_str)?;
|
||||
|
||||
let node = match existing_node {
|
||||
Some(n) => {
|
||||
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
|
||||
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
|
||||
if self.carrier_node_id == Some(n.id)
|
||||
&& (options.write || options.truncate || options.append)
|
||||
{
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
@@ -672,9 +685,7 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
None => {
|
||||
if options.create || options.create_new {
|
||||
let parent = self
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -710,9 +721,7 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -747,9 +756,7 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
let meta = SanctumMetaData {
|
||||
is_dir: node.is_dir,
|
||||
@@ -787,9 +794,7 @@ impl DavFileSystem for SanctumFs {
|
||||
return Err(FsError::Exists);
|
||||
}
|
||||
|
||||
let parent = self
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -809,9 +814,7 @@ impl DavFileSystem for SanctumFs {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -824,7 +827,11 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
// 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)? {
|
||||
if self
|
||||
.db
|
||||
.is_descendant_of(carrier_id, node.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
{
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -845,9 +852,7 @@ impl DavFileSystem for SanctumFs {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -866,11 +871,7 @@ impl DavFileSystem for SanctumFs {
|
||||
})
|
||||
}
|
||||
|
||||
fn rename<'a>(
|
||||
&'a self,
|
||||
from: &'a DavPath,
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.rename(from, to);
|
||||
}
|
||||
@@ -880,9 +881,7 @@ impl DavFileSystem for SanctumFs {
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
|
||||
if self.carrier_node_id == Some(node.id) {
|
||||
@@ -923,24 +922,23 @@ impl DavFileSystem for SanctumFs {
|
||||
})
|
||||
}
|
||||
|
||||
fn copy<'a>(
|
||||
&'a self,
|
||||
from: &'a DavPath,
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
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);
|
||||
|
||||
if self.anti_leak && is_leak_file(to_name) {
|
||||
@@ -984,8 +982,13 @@ impl DavFileSystem for SanctumFs {
|
||||
)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
let (new_ct, new_nonce, new_tag) =
|
||||
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
|
||||
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
|
||||
&self.dek,
|
||||
dest_node.id,
|
||||
idx,
|
||||
&plaintext,
|
||||
self.format_version,
|
||||
)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.db
|
||||
@@ -1018,7 +1021,9 @@ impl DavFileSystem for SanctumFs {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
|
||||
use crate::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use dav_server::fs::OpenOptions;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
@@ -1052,11 +1057,11 @@ mod tests {
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
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 (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||
@@ -1071,7 +1076,8 @@ mod tests {
|
||||
pub struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
pub fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
||||
let p =
|
||||
std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
@@ -1130,7 +1136,10 @@ mod tests {
|
||||
|
||||
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
|
||||
let root_path = DavPath::new("/").unwrap();
|
||||
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut stream = fs_shielded
|
||||
.read_dir(&root_path, ReadDirMeta::None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut names = Vec::new();
|
||||
while let Some(entry) = stream.next().await {
|
||||
let entry = entry.unwrap();
|
||||
@@ -1147,7 +1156,10 @@ mod tests {
|
||||
FORMAT_VERSION,
|
||||
false,
|
||||
);
|
||||
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut stream_unshielded = fs_unshielded
|
||||
.read_dir(&root_path, ReadDirMeta::None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut names_unshielded = Vec::new();
|
||||
while let Some(entry) = stream_unshielded.next().await {
|
||||
let entry = entry.unwrap();
|
||||
@@ -1256,7 +1268,13 @@ mod tests {
|
||||
|
||||
// 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!(
|
||||
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
|
||||
@@ -1281,7 +1299,9 @@ mod tests {
|
||||
|
||||
// 1. Schreibe 500 Bytes
|
||||
let payload = Bytes::from(vec![42u8; 500]);
|
||||
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
|
||||
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();
|
||||
|
||||
+166
-29
@@ -2,6 +2,8 @@ use std::process::Command;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
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').
|
||||
pub fn find_next_available_drive() -> Result<char> {
|
||||
@@ -42,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
||||
Command::new("explorer.exe")
|
||||
.arg(&drive_path)
|
||||
.spawn()
|
||||
.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(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -71,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
Command::new("explorer.exe")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
|
||||
.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()))?;
|
||||
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")))]
|
||||
@@ -87,7 +95,12 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Konnte Dateimanager via xdg-open für '{}' nicht öffnen",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
@@ -127,8 +140,7 @@ pub fn notify_shell_associations_changed() {
|
||||
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 bzw. unter Linux via Freedesktop.
|
||||
@@ -141,11 +153,7 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
|
||||
let reg_commands = [
|
||||
// 1. .sanctum Erweiterung mit ProgID verknüpfen
|
||||
(
|
||||
r"HKCU\Software\Classes\.sanctum",
|
||||
"",
|
||||
"Sanctum.Container",
|
||||
),
|
||||
(r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"),
|
||||
// 2. ProgID Metadaten & Beschreibung
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container",
|
||||
@@ -203,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
}
|
||||
cmd.arg("/d").arg(val_data).arg("/f");
|
||||
|
||||
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
|
||||
@@ -244,9 +254,7 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
||||
];
|
||||
|
||||
for key in keys_to_delete {
|
||||
let _ = Command::new("reg")
|
||||
.args(["delete", key, "/f"])
|
||||
.output();
|
||||
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
|
||||
}
|
||||
|
||||
notify_shell_associations_changed();
|
||||
@@ -256,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
||||
{
|
||||
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"));
|
||||
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(())
|
||||
@@ -494,9 +505,9 @@ pub fn start_session_lock_monitor(
|
||||
})
|
||||
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
|
||||
|
||||
let hwnd = hwnd_rx
|
||||
.recv()
|
||||
.map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?;
|
||||
let hwnd = hwnd_rx.recv().map_err(|e| {
|
||||
anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}")
|
||||
})?;
|
||||
|
||||
if hwnd == 0 {
|
||||
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
|
||||
@@ -520,10 +531,38 @@ pub fn start_session_lock_monitor(
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
|
||||
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
#[cfg(windows)]
|
||||
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
||||
|
||||
#[cfg(windows)]
|
||||
#[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)]
|
||||
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||
const CTRL_C_EVENT: u32 = 0;
|
||||
@@ -534,13 +573,15 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||
|
||||
match ctrl_type {
|
||||
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 Some(dl) = *guard {
|
||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||
let _ = std::process::Command::new("net")
|
||||
.args(["use", &drive_str, "/delete", "/y"])
|
||||
.output();
|
||||
let wide_drive: Vec<u16> =
|
||||
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
|
||||
@@ -681,6 +722,103 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -717,4 +855,3 @@ mod tests {
|
||||
drop(monitor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+146
-41
@@ -7,8 +7,8 @@ use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
||||
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
|
||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
||||
CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::verify::verify_container;
|
||||
@@ -31,8 +31,8 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
@@ -45,8 +45,7 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
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_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();
|
||||
|
||||
@@ -103,7 +102,10 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
|
||||
|
||||
// Metadaten der Alibi-Datei im Decoy prüfen
|
||||
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
|
||||
let carrier_meta = decoy_fs
|
||||
.metadata(&carrier_path)
|
||||
.await
|
||||
.expect("Carrier meta");
|
||||
assert_eq!(carrier_meta.len(), carrier_size_bytes);
|
||||
assert!(!carrier_meta.is_dir());
|
||||
|
||||
@@ -113,20 +115,29 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
|
||||
matches!(
|
||||
decoy_fs.open(&carrier_path, write_opts).await,
|
||||
Err(FsError::Forbidden)
|
||||
),
|
||||
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
|
||||
);
|
||||
|
||||
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
|
||||
assert!(
|
||||
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
|
||||
matches!(
|
||||
decoy_fs.remove_file(&carrier_path).await,
|
||||
Err(FsError::Forbidden)
|
||||
),
|
||||
"Alibi-Datei darf nicht gelöscht werden"
|
||||
);
|
||||
|
||||
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
|
||||
let new_name = DavPath::new("/renamed.iso").unwrap();
|
||||
assert!(
|
||||
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
|
||||
matches!(
|
||||
decoy_fs.rename(&carrier_path, &new_name).await,
|
||||
Err(FsError::Forbidden)
|
||||
),
|
||||
"Alibi-Datei darf nicht umbenannt werden"
|
||||
);
|
||||
|
||||
@@ -135,8 +146,14 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
read: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
|
||||
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
|
||||
let mut file_reader = decoy_fs
|
||||
.open(&carrier_path, read_opts)
|
||||
.await
|
||||
.expect("Open read");
|
||||
let first_mb = file_reader
|
||||
.read_bytes(CHUNK_SIZE)
|
||||
.await
|
||||
.expect("Read first chunk");
|
||||
assert_eq!(first_mb.len(), CHUNK_SIZE);
|
||||
|
||||
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
|
||||
@@ -161,11 +178,17 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
while let Some(item) = stream.next().await {
|
||||
entries.push(item.unwrap().name());
|
||||
}
|
||||
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"Hidden Vault Wurzelverzeichnis muss anfangs leer sein"
|
||||
);
|
||||
|
||||
// Ordner erstellen
|
||||
let secret_dir = DavPath::new("/Classified").unwrap();
|
||||
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
|
||||
hidden_fs
|
||||
.create_dir(&secret_dir)
|
||||
.await
|
||||
.expect("Create Classified dir");
|
||||
|
||||
// Datei im Ordner anlegen und schreiben
|
||||
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
|
||||
@@ -196,14 +219,24 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
.open(&secret_file_path, read_opts)
|
||||
.await
|
||||
.expect("Open secret file for read");
|
||||
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
|
||||
let read_data = read_handle
|
||||
.read_bytes(1024)
|
||||
.await
|
||||
.expect("Read secret bytes");
|
||||
assert_eq!(&read_data[..], secret_content);
|
||||
drop(read_handle);
|
||||
|
||||
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
|
||||
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
|
||||
let mut big_file = hidden_fs
|
||||
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.open(
|
||||
&big_file_path,
|
||||
OpenOptions {
|
||||
create: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Create big file");
|
||||
|
||||
@@ -211,28 +244,46 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
let mut payload = vec![0u8; payload_size];
|
||||
OsRng.fill_bytes(&mut payload);
|
||||
|
||||
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
|
||||
big_file
|
||||
.write_bytes(Bytes::copy_from_slice(&payload))
|
||||
.await
|
||||
.expect("Write big payload");
|
||||
big_file.flush().await.expect("Flush big file");
|
||||
drop(big_file);
|
||||
|
||||
// Datei zurücklesen und Bit-für-Bit verifizieren
|
||||
let mut read_big = hidden_fs
|
||||
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
|
||||
.open(
|
||||
&big_file_path,
|
||||
OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open big file");
|
||||
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
|
||||
let read_big_bytes = read_big
|
||||
.read_bytes(payload_size + 100)
|
||||
.await
|
||||
.expect("Read big file bytes");
|
||||
assert_eq!(read_big_bytes.len(), payload_size);
|
||||
assert_eq!(&read_big_bytes[..], &payload[..]);
|
||||
drop(read_big);
|
||||
|
||||
// Datei umbenennen
|
||||
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
|
||||
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
|
||||
hidden_fs
|
||||
.rename(&big_file_path, &renamed_path)
|
||||
.await
|
||||
.expect("Rename file");
|
||||
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
|
||||
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
|
||||
|
||||
// Datei löschen (Blöcke werden geshreddert und freigegeben)
|
||||
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
|
||||
hidden_fs
|
||||
.remove_file(&renamed_path)
|
||||
.await
|
||||
.expect("Remove file");
|
||||
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
|
||||
|
||||
// Checkpoint SQLite
|
||||
@@ -270,8 +321,8 @@ async fn test_model_a_container_file_size_invariance() {
|
||||
let pass_hidden = "HiddenPass2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
@@ -284,8 +335,7 @@ async fn test_model_a_container_file_size_invariance() {
|
||||
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_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();
|
||||
|
||||
@@ -315,7 +365,10 @@ async fn test_model_a_container_file_size_invariance() {
|
||||
|
||||
// Initiale Dateigröße messen
|
||||
let initial_file_size = std::fs::metadata(&path).unwrap().len();
|
||||
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
|
||||
assert!(
|
||||
initial_file_size >= carrier_size_bytes,
|
||||
"Containergröße muss mindestens 10 MB betragen"
|
||||
);
|
||||
|
||||
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
|
||||
let meta = db.read_meta().unwrap();
|
||||
@@ -333,13 +386,23 @@ async fn test_model_a_container_file_size_invariance() {
|
||||
|
||||
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
|
||||
let mut handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.open(
|
||||
&test_file,
|
||||
OpenOptions {
|
||||
create: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open file");
|
||||
|
||||
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
|
||||
OsRng.fill_bytes(&mut random_data);
|
||||
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
|
||||
handle
|
||||
.write_bytes(Bytes::copy_from_slice(&random_data))
|
||||
.await
|
||||
.expect("Write 4MB");
|
||||
handle.flush().await.expect("Flush 4MB");
|
||||
drop(handle);
|
||||
|
||||
@@ -369,8 +432,8 @@ async fn test_carrier_file_drop_and_append_mode() {
|
||||
let pass_hidden = "HiddenSecretPassword2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
@@ -383,8 +446,7 @@ async fn test_carrier_file_drop_and_append_mode() {
|
||||
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_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();
|
||||
|
||||
@@ -428,44 +490,87 @@ async fn test_carrier_file_drop_and_append_mode() {
|
||||
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
|
||||
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
||||
let mut write_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.open(
|
||||
&test_file,
|
||||
OpenOptions {
|
||||
create: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open file for write");
|
||||
|
||||
let initial_data = b"Hello from unflushed write!";
|
||||
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
|
||||
write_handle
|
||||
.write_bytes(Bytes::from_static(initial_data))
|
||||
.await
|
||||
.expect("Write initial data");
|
||||
// WICHTIG: KEIN write_handle.flush()! Nur drop:
|
||||
drop(write_handle);
|
||||
|
||||
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
||||
let mut read_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||
.open(
|
||||
&test_file,
|
||||
OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open file for read");
|
||||
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
|
||||
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
|
||||
assert_eq!(
|
||||
&read_back[..],
|
||||
initial_data,
|
||||
"Drop muss ungeflushte Datenblöcke und Inode automatisch sichern"
|
||||
);
|
||||
drop(read_handle);
|
||||
|
||||
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
||||
let append_data = b" - Appended data at EOF!";
|
||||
let mut append_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
|
||||
.open(
|
||||
&test_file,
|
||||
OpenOptions {
|
||||
write: true,
|
||||
append: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open file for append");
|
||||
|
||||
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
|
||||
append_handle
|
||||
.write_bytes(Bytes::from_static(append_data))
|
||||
.await
|
||||
.expect("Write appended data");
|
||||
drop(append_handle); // Drop sichert auch hier
|
||||
|
||||
// Prüfe den vollständigen Dateiinhalt nach Append
|
||||
let mut read_handle_2 = hidden_fs
|
||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||
.open(
|
||||
&test_file,
|
||||
OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Open file for read after append");
|
||||
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
|
||||
let full_content = read_handle_2
|
||||
.read_bytes(200)
|
||||
.await
|
||||
.expect("Read full content");
|
||||
let mut expected = Vec::new();
|
||||
expected.extend_from_slice(initial_data);
|
||||
expected.extend_from_slice(append_data);
|
||||
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
|
||||
assert_eq!(
|
||||
&full_content[..],
|
||||
&expected[..],
|
||||
"O_APPEND muss Daten am Dateiende anhängen"
|
||||
);
|
||||
drop(read_handle_2);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
+546
-151
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ use sanctum::{
|
||||
#[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()));
|
||||
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);
|
||||
}
|
||||
@@ -28,8 +31,8 @@ async fn test_live_crash_and_recovery_stress() {
|
||||
let password = "LiveStressPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
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");
|
||||
@@ -105,13 +108,17 @@ async fn test_live_crash_and_recovery_stress() {
|
||||
|
||||
// 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");
|
||||
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");
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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, uri_contains_token};
|
||||
|
||||
let session_token = "4f8a12bc90de45f187a23456789abcde";
|
||||
let port = 8443;
|
||||
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||
|
||||
// R-05 & SA-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 (Constant-Time)
|
||||
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));
|
||||
|
||||
let wrong_auth = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode("sanctum:wrong_token_1234567890abcdef")
|
||||
);
|
||||
assert!(!check_basic_auth(&wrong_auth, session_token));
|
||||
|
||||
// 2. Token Header Prüfung (Constant-Time)
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
||||
assert!(check_token_header(&headers, session_token));
|
||||
|
||||
let mut wrong_headers = hyper::HeaderMap::new();
|
||||
wrong_headers.insert(
|
||||
"X-Sanctum-Token",
|
||||
"wrong_token_1234567890abcdef".parse().unwrap(),
|
||||
);
|
||||
assert!(!check_token_header(&wrong_headers, session_token));
|
||||
|
||||
// 3. SA-05: Verifikation, dass Token in URI (Pfad oder Query) erkannt und strikt abgewiesen wird
|
||||
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(
|
||||
uri_contains_token(&uri_path, session_token),
|
||||
"Token im Pfad muss erkannt werden"
|
||||
);
|
||||
|
||||
let uri_query: hyper::Uri = format!("http://127.0.0.1:8443/test.txt?token={}", session_token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(
|
||||
uri_contains_token(&uri_query, session_token),
|
||||
"Token im Query-String muss erkannt werden"
|
||||
);
|
||||
|
||||
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/test.txt".parse().unwrap();
|
||||
assert!(
|
||||
!uri_contains_token(&uri_clean, session_token),
|
||||
"Saubere URI darf kein Token enthalten"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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,224 @@
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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,271 @@
|
||||
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,679 @@
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||
MIN_TIME_COST,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_slot_amplification_and_excessive_slots_dos() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||
|
||||
// 1. Fall: 0 Slots (leere meta-Tabelle)
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
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("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
|
||||
.unwrap();
|
||||
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err());
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("leer oder beschädigt"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
|
||||
.unwrap();
|
||||
}
|
||||
let db_reopened = Database::open(&db_path).unwrap();
|
||||
let slots = db_reopened
|
||||
.read_slots()
|
||||
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0].slot_id, 0);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
let slots = db
|
||||
.read_slots()
|
||||
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
|
||||
assert_eq!(slots.len(), 2);
|
||||
assert_eq!(slots[0].slot_id, 0);
|
||||
assert_eq!(slots[1].slot_id, 1);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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();
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"Database::open muss 3 Slots sofort abweisen"
|
||||
);
|
||||
let err_msg = open_res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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();
|
||||
for i in 0..100 {
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"100 Slots müssen sofort abgewiesen werden"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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();
|
||||
let mut stmt = conn
|
||||
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
|
||||
.unwrap();
|
||||
for i in 0..1000 {
|
||||
stmt.execute(rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"1000 Slots müssen sofort abgewiesen werden"
|
||||
);
|
||||
let err_msg = open_res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_duplicate_and_invalid_slot_ids() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||
|
||||
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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 VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Doppelte Slot-ID 0"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
|
||||
{
|
||||
let db_path = temp_dir.join(format!(
|
||||
"test_invalid_slot_id_{}.sanctum",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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 VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-ID 5"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
|
||||
{
|
||||
let db_path = temp_dir.join(format!(
|
||||
"test_missing_slot_0_{}.sanctum",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, 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 VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_meta_authenticate_defense_in_depth() {
|
||||
use sanctum::storage::{ContainerMeta, SlotMeta};
|
||||
|
||||
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let valid_slot_0 = SlotMeta {
|
||||
slot_id: 0,
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
};
|
||||
|
||||
let valid_slot_1 = SlotMeta {
|
||||
slot_id: 1,
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
};
|
||||
|
||||
// A. Leere Slots -> sofort None
|
||||
let meta_empty = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![],
|
||||
};
|
||||
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
|
||||
let meta_3_slots = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![
|
||||
valid_slot_0.clone(),
|
||||
valid_slot_1.clone(),
|
||||
valid_slot_0.clone(),
|
||||
],
|
||||
};
|
||||
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
|
||||
let meta_no_slot0 = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_1.clone()],
|
||||
};
|
||||
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// D. Ungültige Slot-ID (> 1) -> sofort None
|
||||
let mut invalid_slot = valid_slot_0.clone();
|
||||
invalid_slot.slot_id = 99;
|
||||
let meta_invalid_id = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_0.clone(), invalid_slot],
|
||||
};
|
||||
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
|
||||
let meta_valid_1 = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_0.clone()],
|
||||
};
|
||||
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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,161 @@
|
||||
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