release: v0.7.1 — Security-Patch (R-01 bis R-06)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
- R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code (LEGAL.md, README.md, QUICKSTART.md, main.rs, crypto/storage/recovery/verify.rs) - R-02: Lückenloser Carrier-Schutz in SanctumFs::copy (Quelle & Ziel) und sync (Pull-Skip & Push-Schutz) - R-03: Shared Dateinamen-Validierung (validate_node_name) in pathutil.rs, durchgesetzt in storage.rs und vfs.rs - R-04: Fail-closed Release-Packaging & obligatorische Minisign-Signatur in CI (.gitea/workflows/release.yaml) und Scripts - R-05: Session-Token Beseitigung im argv: In-Process Win32 WNetAddConnection2W/WNetCancelConnection2W, kein gio argv-Token, Multi-Auth HTTP Middleware (Basic Auth, X-Sanctum-Token, Path-Fallback) mit 401 WWW-Authenticate - R-06: Sofortiges Löschen von SANCTUM_RECOVERY_KEY aus der Prozessumgebung
This commit is contained in:
@@ -51,6 +51,35 @@ jobs:
|
|||||||
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
||||||
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Sign Checksums with Minisign
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
run: |
|
||||||
|
if (-not $env:MINISIGN_SECRET_KEY) {
|
||||||
|
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) {
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip" -OutFile "minisign.zip"
|
||||||
|
Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin"
|
||||||
|
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
|
||||||
|
} else {
|
||||||
|
$MinisignExe = "minisign"
|
||||||
|
}
|
||||||
|
|
||||||
|
$KeyFile = "sanctum-ci-release.key"
|
||||||
|
[System.IO.File]::WriteAllText($KeyFile, $env:MINISIGN_SECRET_KEY)
|
||||||
|
try {
|
||||||
|
& $MinisignExe -S -s $KeyFile -m "dist/SHA256SUMS.txt" -W -x "dist/SHA256SUMS.txt.minisig"
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "dist/SHA256SUMS.txt.minisig")) {
|
||||||
|
Write-Error "Minisign-Signierung fehlgeschlagen!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-Item $KeyFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -58,6 +87,7 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/SHA256SUMS.txt
|
dist/SHA256SUMS.txt
|
||||||
|
dist/SHA256SUMS.txt.minisig
|
||||||
target/release/sanctum.exe
|
target/release/sanctum.exe
|
||||||
body_path: CHANGELOG.md
|
body_path: CHANGELOG.md
|
||||||
draft: false
|
draft: false
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
|
|||||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
|
||||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-09-18
|
||||||
|
|
||||||
|
### Security Patch Release (Closing Gaps R-01 to R-06)
|
||||||
|
Dieses Release schließt sechs identifizierte Restlücken (R-01 bis R-06) aus dem Security-Review von v0.7.0 und härtet Authentifizierung, Prozessisolation, Carrier-Integrität und CI/CD-Sicherheit.
|
||||||
|
|
||||||
|
#### Security & Architecture
|
||||||
|
- **R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code**:
|
||||||
|
- `LEGAL.md`: Das „100% Offline“-Versprechen wurde präzisiert. Es wird wahrheitsgemäß dokumentiert, dass keine Telemetrie oder stilles Phone-Home existiert, der Befehl `sanctum upgrade` (und `--check`) jedoch bei explizitem Nutzeraufruf die Release-API des konfigurierten Gitea-Hosts anspricht.
|
||||||
|
- Entfernung aller verbliebenen Formulierungen über unnachweisbare Abstreitbarkeit ("Plausible Deniability") oder RIPA 49.
|
||||||
|
- Neutralisierung der Nutzer- und Entwicklerdokumentation bezüglich der Alibi-Trägerdatei im Decoy-Vault.
|
||||||
|
- **R-02: Lückenloser Schutz der Trägerdatei in VFS und Sync**:
|
||||||
|
- `SanctumFs::copy`: Das Duplizieren der Trägerdatei als Quelle sowie das Überschreiben als Ziel wird nun mit `FsError::Forbidden` fail-closed abgewehrt.
|
||||||
|
- `sanctum sync` (Pull): `sync_single_file_to_host` verweigert den Abzug der Trägerdatei via `assert_not_carrier`. Der rekursive Pull-Vorgang (`collect_and_pull_dir`) überspringt die Trägerdatei vollständig und spiegelt sie nicht auf den Host.
|
||||||
|
- `sanctum sync` (Push): Verhindert das Überschreiben der bestehenden Trägerdatei durch Push-Operationen.
|
||||||
|
- **R-03: Vereinheitlichte Validierung von Dateinamen (Path-Traversal & Gerätenamen)**:
|
||||||
|
- Auslagerung der kanonischen Prüffunktion `validate_node_name` in das gemeinsame Modul `src/pathutil.rs`.
|
||||||
|
- Durchgängige Durchsetzung in `storage.rs` (`create_node_in_vault`, `rename_node_in_vault`) und `vfs.rs` (`create_node`, `rename_node`).
|
||||||
|
- Strikte Zurückweisung von Pfadtrennern, Null-Bytes, Steuerzeichen, Verzeichnisreferenzen (`.`, `..`) und reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
||||||
|
- **R-04: Fail-Closed Release-Packaging und CI/CD-Signaturpflicht**:
|
||||||
|
- `package-release.ps1` und `package-release-linux.ps1`: Das Fehlen von `minisign.exe` oder `sanctum-release.key` bricht die Paketerstellung sofort mit `exit 1` ab (kein Warning-and-continue mehr).
|
||||||
|
- `publish-release.ps1`: Bricht die Veröffentlichung mit Fehler ab, wenn `SHA256SUMS.txt.minisig` fehlt.
|
||||||
|
- `.gitea/workflows/release.yaml`: Automatischer Minisign-Signaturschritt mittels Secret `MINISIGN_SECRET_KEY` und Pflicht-Upload von `SHA256SUMS.txt.minisig` zu jedem Gitea-Release.
|
||||||
|
- **R-05: Beseitigung von Session-Token im Argumentvektor (argv)**:
|
||||||
|
- Windows Netzlaufwerk-Mounting: Vollständige Umstellung von externem `net use` Kindprozess auf die in-process Win32-APIs `WNetAddConnection2W` und `WNetCancelConnection2W` (aus `mpr.dll`). Das Session-Token wird als Passwort übergeben; die Remote-URL `http://127.0.0.1:<port>/` enthält kein Token mehr im Prozess-Argumentvektor oder in der Prozessliste.
|
||||||
|
- Linux: Beseitigung des Tokens aus `gio mount` Argumenten.
|
||||||
|
- WebDAV HTTP-Middleware: Unterstützung von HTTP Basic Auth (`Authorization: Basic <base64>`), `X-Sanctum-Token`-Header und Pfad-Präfix-Fallback (`/<token>/`). Unauthentifizierte Anfragen werden mit standardkonformem `401 Unauthorized` und `WWW-Authenticate: Basic realm="Sanctum"` beantwortet; ungültige Host-Header mit `403 Forbidden`.
|
||||||
|
- **R-06: Speicher-Hygiene bei Notfallschlüsseln**:
|
||||||
|
- In `src/main.rs` wird die Umgebungsvariable `SANCTUM_RECOVERY_KEY` nach dem erfolgreichen Einlesen sofort mittels `std::env::remove_var` aus dem Prozessspeicher entfernt.
|
||||||
|
|
||||||
## [0.7.0] - 2026-09-18
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
### Security Hardening Release
|
### Security Hardening Release
|
||||||
|
|||||||
Generated
+2
-1
@@ -1457,11 +1457,12 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.0"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"base64",
|
||||||
"bip39",
|
"bip39",
|
||||||
"bytes",
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.0"
|
version = "0.7.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||||
@@ -12,6 +12,7 @@ keywords = ["encryption", "container", "windows", "webdav", "security"]
|
|||||||
categories = ["cryptography", "command-line-utilities", "filesystem"]
|
categories = ["cryptography", "command-line-utilities", "filesystem"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
rpassword = "7.3"
|
rpassword = "7.3"
|
||||||
argon2 = { version = "0.5", features = ["password-hash"] }
|
argon2 = { version = "0.5", features = ["password-hash"] }
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **Wichtiger Sicherheitshinweis & Haftungsausschluss für Anwender**:
|
> **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.
|
> - **Schutzziel**: Die Dual-Vault-Funktion dient dem Schutz vor neugierigen Blicken (Schulterblick), Diebstahl oder beiläufigem Zwang im Alltag, indem ein plausibler Alibi-Tresor mit unkritischen Daten vorgezeigt werden kann.
|
||||||
> - **KEINE forensische "Plausible Deniability"**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält.
|
> - **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 ("Plausible Deniability") und ist **nicht** als RIPA-49-Schutzschild konzipiert.
|
> - **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)**:
|
> 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.
|
> * **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.
|
||||||
@@ -64,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):
|
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.
|
* **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.
|
||||||
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
|
* **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.
|
* **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.
|
* **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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
|||||||
- **Swap-Attack-Schutz**:
|
- **Swap-Attack-Schutz**:
|
||||||
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
||||||
- **Dual-Vault (Multi-Slot & Carrier)**:
|
- **Dual-Vault (Multi-Slot & Carrier)**:
|
||||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristische "Plausible Deniability" gegen behördliche Beschlagnahme).
|
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
||||||
- **Dateinamen-Verschlüsselung**:
|
- **Dateinamen-Verschlüsselung**:
|
||||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||||
- **Kryptografisches Chunk-Shredding**:
|
- **Kryptografisches Chunk-Shredding**:
|
||||||
|
|||||||
@@ -118,8 +118,17 @@ if (Test-Path $KeyFile) {
|
|||||||
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
|
||||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
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)
|
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ if (Test-Path $KeyFile) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "minisign.exe nicht gefunden ($MinisignExe). Signatur wurde uebersprungen."
|
Write-Error "minisign.exe nicht gefunden ($MinisignExe)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "sanctum-release.key nicht gefunden ($KeyFile). Signatur wurde uebersprungen."
|
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Abschluss-Zusammenfassung
|
# Abschluss-Zusammenfassung
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ if (-not (Test-Path $ZipFile)) {
|
|||||||
& (Join-Path $ScriptDir "package-release.ps1")
|
& (Join-Path $ScriptDir "package-release.ps1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||||
|
if (-not (Test-Path $SigFile)) {
|
||||||
|
Write-Error "Obligatorische Minisign-Signaturdatei '$SigFile' nicht gefunden! Ein Release ohne kryptografische Signatur darf nicht veroeffentlicht werden."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# .env-Datei laden falls vorhanden (ist in .gitignore)
|
# .env-Datei laden falls vorhanden (ist in .gitignore)
|
||||||
$EnvFile = Join-Path $ProjectRoot ".env"
|
$EnvFile = Join-Path $ProjectRoot ".env"
|
||||||
if (Test-Path $EnvFile) {
|
if (Test-Path $EnvFile) {
|
||||||
|
|||||||
+1
-2
@@ -241,8 +241,7 @@ pub fn wrap_slot1_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
|
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
|
||||||
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
|
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload) für Dummy-Slot-Längenparität im Header.
|
||||||
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
|
|
||||||
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
|
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
|
||||||
let mut wrapped_dek = vec![0u8; 72];
|
let mut wrapped_dek = vec![0u8; 72];
|
||||||
let mut nonce = [0u8; 12];
|
let mut nonce = [0u8; 12];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod carrier;
|
pub mod carrier;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
|
pub mod pathutil;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|||||||
+4
-4
@@ -45,10 +45,8 @@ enum Commands {
|
|||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
with_hidden: bool,
|
with_hidden: bool,
|
||||||
|
|
||||||
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
|
/// Dateiname der Alibi-Trägerdatei im Standard-Tresor (Standard: system_backup.dat).
|
||||||
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
|
/// Hinweis: Die Datei dient als Träger für den zweiten Tresor und ist forensisch erkennbar.
|
||||||
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
|
|
||||||
/// in forensischen Entropie-Scannern zu verhindern.
|
|
||||||
#[arg(long, default_value = "system_backup.dat")]
|
#[arg(long, default_value = "system_backup.dat")]
|
||||||
carrier_name: String,
|
carrier_name: String,
|
||||||
|
|
||||||
@@ -340,6 +338,8 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
|
|||||||
Ok(Some(Zeroizing::new(key.to_string())))
|
Ok(Some(Zeroizing::new(key.to_string())))
|
||||||
}
|
}
|
||||||
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
|
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
|
||||||
|
// R-06: Umgebungsvariable nach dem Einlesen sofort aus dem Speicher des Prozesses entfernen
|
||||||
|
std::env::remove_var("SANCTUM_RECOVERY_KEY");
|
||||||
let trimmed = env_key.trim();
|
let trimmed = env_key.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ"));
|
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ"));
|
||||||
|
|||||||
+180
-97
@@ -1,6 +1,7 @@
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -32,29 +33,11 @@ pub fn format_drive(drive_letter: char) -> String {
|
|||||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` 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<()> {
|
pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let drive_str = format_drive(drive_letter);
|
crate::windows::unmount_drive_wnet(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(())
|
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -68,74 +51,25 @@ 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
|
/// 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>/<TOKEN>/`.
|
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/`.
|
||||||
fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
|
crate::windows::mount_drive_wnet(drive_letter, 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(())
|
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
|
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
||||||
let _ = Command::new("gio")
|
let _ = Command::new("gio")
|
||||||
.args(["mount", &dav_url])
|
.args(["mount", &dav_url])
|
||||||
.output();
|
.output();
|
||||||
let _ = drive_str;
|
let _ = (drive_letter, session_token);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(not(any(windows, unix)))]
|
#[cfg(not(any(windows, unix)))]
|
||||||
{
|
{
|
||||||
let _ = (drive_str, port, session_token);
|
let _ = (drive_letter, port, session_token);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +89,7 @@ pub async fn mount_container(
|
|||||||
stealth: bool,
|
stealth: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let drive_str = format_drive(drive_letter);
|
let drive_str = format_drive(drive_letter);
|
||||||
|
let _ = &drive_str;
|
||||||
let _ = mount_point;
|
let _ = mount_point;
|
||||||
let _ = open_explorer;
|
let _ = open_explorer;
|
||||||
let _ = enable_tray;
|
let _ = enable_tray;
|
||||||
@@ -237,7 +172,6 @@ pub async fn mount_container(
|
|||||||
let mut token_bytes = [0u8; 16];
|
let mut token_bytes = [0u8; 16];
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||||
let session_token = hex::encode(token_bytes);
|
let session_token = hex::encode(token_bytes);
|
||||||
let token_path_prefix = format!("/{}", session_token);
|
|
||||||
|
|
||||||
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
||||||
let fs = SanctumFs::with_carrier(
|
let fs = SanctumFs::with_carrier(
|
||||||
@@ -251,7 +185,6 @@ pub async fn mount_container(
|
|||||||
);
|
);
|
||||||
let last_activity = fs.last_activity();
|
let last_activity = fs.last_activity();
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.strip_prefix(token_path_prefix.clone())
|
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
.locksystem(FakeLs::new())
|
.locksystem(FakeLs::new())
|
||||||
.build_handler();
|
.build_handler();
|
||||||
@@ -302,12 +235,12 @@ pub async fn mount_container(
|
|||||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||||
listener,
|
listener,
|
||||||
dav_server,
|
dav_server,
|
||||||
token_path_prefix.clone(),
|
session_token.clone(),
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
// 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 _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
@@ -416,12 +349,12 @@ pub async fn mount_container(
|
|||||||
println!(" • Modus: WebDAV Userland-VFS");
|
println!(" • Modus: WebDAV Userland-VFS");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
|
println!(" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)", bound_port);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
|
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
|
||||||
if let Some(mp) = mount_point {
|
if let Some(mp) = mount_point {
|
||||||
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
|
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(secs) = idle_timeout {
|
if let Some(secs) = idle_timeout {
|
||||||
@@ -681,14 +614,88 @@ const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
|||||||
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
|
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
|
||||||
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
|
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
|
||||||
|
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
|
||||||
|
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
|
||||||
|
let auth_str = auth_header.trim();
|
||||||
|
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
|
||||||
|
rest
|
||||||
|
} else if let Some(rest) = auth_str.strip_prefix("basic ") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let decoded_bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.trim()) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded_str = match std::str::from_utf8(&decoded_bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
||||||
|
pass == expected_token
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
|
||||||
|
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
|
||||||
|
if let Some(val) = headers.get("X-Sanctum-Token") {
|
||||||
|
if let Ok(val_str) = val.to_str() {
|
||||||
|
return val_str.trim() == expected_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
|
||||||
|
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
|
||||||
|
let path = uri.path();
|
||||||
|
if !path.starts_with(prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &path[prefix.len()..];
|
||||||
|
let new_path = if rest.is_empty() || !rest.starts_with('/') {
|
||||||
|
format!("/{}", rest)
|
||||||
|
} else {
|
||||||
|
rest.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let path_and_query = match uri.query() {
|
||||||
|
Some(q) => format!("{}?{}", new_path, q),
|
||||||
|
None => new_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = uri.clone().into_parts();
|
||||||
|
parts.path_and_query = Some(path_and_query.parse().ok()?);
|
||||||
|
hyper::Uri::from_parts(parts).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
|
||||||
|
fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
|
||||||
|
hyper::Response::builder()
|
||||||
|
.status(hyper::StatusCode::UNAUTHORIZED)
|
||||||
|
.header(hyper::header::WWW_AUTHENTICATE, "Basic realm=\"Sanctum\"")
|
||||||
|
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||||
|
.body(dav_server::body::Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
|
||||||
pub async fn serve_webdav_loop(
|
pub async fn serve_webdav_loop(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
dav_server: DavHandler,
|
dav_server: DavHandler,
|
||||||
token_path_prefix: String,
|
session_token: String,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||||
|
let token_prefix = format!("/{}", session_token);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -716,13 +723,15 @@ pub async fn serve_webdav_loop(
|
|||||||
|
|
||||||
let io = TokioIo::new(stream);
|
let io = TokioIo::new(stream);
|
||||||
let handler = dav_server.clone();
|
let handler = dav_server.clone();
|
||||||
let expected_prefix = token_path_prefix.clone();
|
let expected_token = session_token.clone();
|
||||||
|
let expected_prefix = token_prefix.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||||
|
|
||||||
let service = service_fn(move |req| {
|
let service = service_fn(move |mut req| {
|
||||||
let h = handler.clone();
|
let h = handler.clone();
|
||||||
|
let token = expected_token.clone();
|
||||||
let prefix = expected_prefix.clone();
|
let prefix = expected_prefix.clone();
|
||||||
async move {
|
async move {
|
||||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||||
@@ -746,15 +755,36 @@ pub async fn serve_webdav_loop(
|
|||||||
return Ok::<_, Infallible>(res);
|
return Ok::<_, Infallible>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
|
// 2. R-05: Multi-Auth Middleware
|
||||||
let path = req.uri().path();
|
// a) HTTP Basic Auth (Authorization: Basic ...)
|
||||||
if !path.starts_with(&prefix) {
|
// b) Header X-Sanctum-Token
|
||||||
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
|
// c) Pfad-Präfix Fallback (/<session_token>/...)
|
||||||
let res = hyper::Response::builder()
|
let mut authenticated = false;
|
||||||
.status(hyper::StatusCode::FORBIDDEN)
|
|
||||||
.body(dav_server::body::Body::empty())
|
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||||
.unwrap();
|
if let Ok(auth_str) = auth_val.to_str() {
|
||||||
return Ok::<_, Infallible>(res);
|
if check_basic_auth(auth_str, &token) {
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated && check_token_header(req.headers(), &token) {
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||||
|
*req.uri_mut() = rewritten_uri;
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
} else if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||||
|
*req.uri_mut() = rewritten_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticated {
|
||||||
|
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
||||||
|
return Ok::<_, Infallible>(unauthorized_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Infallible>(h.handle(req).await)
|
Ok::<_, Infallible>(h.handle(req).await)
|
||||||
@@ -767,7 +797,6 @@ pub async fn serve_webdav_loop(
|
|||||||
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
||||||
|
|
||||||
if let Err(err) = builder.serve_connection(io, service).await {
|
if let Err(err) = builder.serve_connection(io, service).await {
|
||||||
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
|
|
||||||
debug!("HTTP-Verbindungsende: {:?}", err);
|
debug!("HTTP-Verbindungsende: {:?}", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -806,6 +835,60 @@ mod tests {
|
|||||||
assert_eq!(format_drive('s'), "S:");
|
assert_eq!(format_drive('s'), "S:");
|
||||||
assert_eq!(format_drive('Z'), "Z:");
|
assert_eq!(format_drive('Z'), "Z:");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_basic_auth() {
|
||||||
|
use base64::Engine;
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let creds = format!("sanctum:{}", token);
|
||||||
|
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
|
||||||
|
assert!(check_basic_auth(&header_val, token));
|
||||||
|
|
||||||
|
// Kleingeschriebenes basic Präfix
|
||||||
|
let lower_header = format!("basic {}", base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token)));
|
||||||
|
assert!(check_basic_auth(&lower_header, token));
|
||||||
|
|
||||||
|
// Falsches Token
|
||||||
|
let wrong_token_header = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken"));
|
||||||
|
assert!(!check_basic_auth(&wrong_token_header, token));
|
||||||
|
|
||||||
|
// Ungültiges Base64 oder Format
|
||||||
|
assert!(!check_basic_auth("Basic !!!notbase64!!!", token));
|
||||||
|
assert!(!check_basic_auth("Bearer 12345", token));
|
||||||
|
assert!(!check_basic_auth("", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_header() {
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let mut headers = hyper::HeaderMap::new();
|
||||||
|
assert!(!check_token_header(&headers, token));
|
||||||
|
|
||||||
|
headers.insert("X-Sanctum-Token", token.parse().unwrap());
|
||||||
|
assert!(check_token_header(&headers, token));
|
||||||
|
|
||||||
|
let mut wrong_headers = hyper::HeaderMap::new();
|
||||||
|
wrong_headers.insert("X-Sanctum-Token", "wrong".parse().unwrap());
|
||||||
|
assert!(!check_token_header(&wrong_headers, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_path_prefix() {
|
||||||
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
|
let prefix = format!("/{}", token);
|
||||||
|
|
||||||
|
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg?sort=date", token).parse().unwrap();
|
||||||
|
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
|
||||||
|
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
|
||||||
|
assert_eq!(stripped.query(), Some("sort=date"));
|
||||||
|
|
||||||
|
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
|
||||||
|
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
|
||||||
|
assert_eq!(stripped_root.path(), "/");
|
||||||
|
|
||||||
|
let uri_no_prefix: hyper::Uri = "http://127.0.0.1:8443/other/path".parse().unwrap();
|
||||||
|
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
||||||
|
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
||||||
|
pub fn validate_node_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("Dateiname darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
|
||||||
|
}
|
||||||
|
if name.contains('/') || name.contains('\\') || name.contains('\0') {
|
||||||
|
bail!(
|
||||||
|
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for c in name.chars() {
|
||||||
|
if (c as u32) < 0x20 {
|
||||||
|
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
|
||||||
|
let base_name = if let Some(dot_idx) = name.find('.') {
|
||||||
|
&name[..dot_idx]
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
let base_upper = base_name.to_ascii_uppercase();
|
||||||
|
let reserved = [
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
];
|
||||||
|
if reserved.contains(&base_upper.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_node_name() {
|
||||||
|
assert!(validate_node_name("test.txt").is_ok());
|
||||||
|
assert!(validate_node_name("").is_err());
|
||||||
|
assert!(validate_node_name(".").is_err());
|
||||||
|
assert!(validate_node_name("..").is_err());
|
||||||
|
assert!(validate_node_name("foo/bar").is_err());
|
||||||
|
assert!(validate_node_name("foo\\bar").is_err());
|
||||||
|
assert!(validate_node_name("CON").is_err());
|
||||||
|
assert!(validate_node_name("aux.txt").is_err());
|
||||||
|
assert!(validate_node_name("nul").is_err());
|
||||||
|
assert!(validate_node_name("com1.log").is_err());
|
||||||
|
assert!(validate_node_name("line\nbreak").is_err());
|
||||||
|
assert!(validate_node_name("null\0byte").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -314,7 +314,7 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
existing_slots.push(new_slot);
|
existing_slots.push(new_slot);
|
||||||
existing_slots.sort_by_key(|s| s.slot_id);
|
existing_slots.sort_by_key(|s| s.slot_id);
|
||||||
|
|
||||||
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
|
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Dummy-Slot-Längenparität
|
||||||
if !existing_slots.iter().any(|s| s.slot_id == 1) {
|
if !existing_slots.iter().any(|s| s.slot_id == 1) {
|
||||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
let dummy_slot = SlotMeta {
|
let dummy_slot = SlotMeta {
|
||||||
|
|||||||
+7
-5
@@ -639,7 +639,7 @@ impl Database {
|
|||||||
Ok(carrier_node_id)
|
Ok(carrier_node_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
|
/// Initialisiert das Datenbankschema mit Unterstützung für Dual-Vault (optionaler Hidden Vault).
|
||||||
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
|
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
|
||||||
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
|
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
|
||||||
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
|
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
|
||||||
@@ -720,7 +720,7 @@ impl Database {
|
|||||||
params![now, now],
|
params![now, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Slot 1: Entweder echter Hidden Vault ODER ununterscheidbares kryptografisches Rauschen (Plausible Deniability)
|
// Slot 1: Entweder echter Hidden Vault ODER CSPRNG-Rauschen (Dummy-Slot-Längenparität)
|
||||||
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
|
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
|
||||||
let params_json_1 = serde_json::to_string(h_params)?;
|
let params_json_1 = serde_json::to_string(h_params)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -758,7 +758,7 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability).
|
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot-Längenparität).
|
||||||
pub fn init_schema(
|
pub fn init_schema(
|
||||||
&self,
|
&self,
|
||||||
salt: &[u8; 16],
|
salt: &[u8; 16],
|
||||||
@@ -1191,6 +1191,7 @@ impl Database {
|
|||||||
is_dir: bool,
|
is_dir: bool,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
) -> Result<NodeRecord> {
|
) -> Result<NodeRecord> {
|
||||||
|
crate::pathutil::validate_node_name(name)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
@@ -1299,6 +1300,7 @@ impl Database {
|
|||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
self.assert_not_carrier(id)?;
|
self.assert_not_carrier(id)?;
|
||||||
|
crate::pathutil::validate_node_name(new_name)?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let stored_name = if vault_id == 1 {
|
let stored_name = if vault_id == 1 {
|
||||||
@@ -1590,7 +1592,7 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
|
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Längenparität
|
||||||
if !has_slot1 {
|
if !has_slot1 {
|
||||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
||||||
@@ -1910,7 +1912,7 @@ mod tests {
|
|||||||
params![hidden_file.id],
|
params![hidden_file.id],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
// Plausible Deniability: Kein $h$-Präfix, kein Klartext
|
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
|
||||||
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
||||||
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
||||||
|
|
||||||
|
|||||||
+9
-41
@@ -13,47 +13,7 @@ use crate::storage::{Database, NodeRecord};
|
|||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::vfs::is_leak_file;
|
use crate::vfs::is_leak_file;
|
||||||
|
|
||||||
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
|
pub use crate::pathutil::validate_node_name;
|
||||||
pub fn validate_node_name(name: &str) -> Result<()> {
|
|
||||||
if name.is_empty() {
|
|
||||||
bail!("Dateiname darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
if name == "." || name == ".." {
|
|
||||||
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
|
|
||||||
}
|
|
||||||
if name.contains('/') || name.contains('\\') || name.contains('\0') {
|
|
||||||
bail!(
|
|
||||||
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for c in name.chars() {
|
|
||||||
if (c as u32) < 0x20 {
|
|
||||||
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
|
|
||||||
let base_name = if let Some(dot_idx) = name.find('.') {
|
|
||||||
&name[..dot_idx]
|
|
||||||
} else {
|
|
||||||
name
|
|
||||||
};
|
|
||||||
let base_upper = base_name.to_ascii_uppercase();
|
|
||||||
let reserved = [
|
|
||||||
"CON", "PRN", "AUX", "NUL",
|
|
||||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
||||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
||||||
];
|
|
||||||
if reserved.contains(&base_upper.as_str()) {
|
|
||||||
bail!(
|
|
||||||
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
|
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
|
||||||
fn calc_local_file_sha256(path: &Path) -> Result<String> {
|
fn calc_local_file_sha256(path: &Path) -> Result<String> {
|
||||||
@@ -288,6 +248,7 @@ pub fn sync_single_file_to_vault(
|
|||||||
let existing_node = children.into_iter().find(|c| c.name == file_name);
|
let existing_node = children.into_iter().find(|c| c.name == file_name);
|
||||||
|
|
||||||
if let Some(ref node) = existing_node {
|
if let Some(ref node) = existing_node {
|
||||||
|
db.assert_not_carrier(node.id)?;
|
||||||
if node.is_dir {
|
if node.is_dir {
|
||||||
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
||||||
}
|
}
|
||||||
@@ -370,6 +331,7 @@ pub fn sync_single_file_to_host(
|
|||||||
checksum: bool,
|
checksum: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<FileTransferResult> {
|
) -> Result<FileTransferResult> {
|
||||||
|
db.assert_not_carrier(node.id)?;
|
||||||
validate_node_name(&node.name)?;
|
validate_node_name(&node.name)?;
|
||||||
|
|
||||||
if local_path.exists() {
|
if local_path.exists() {
|
||||||
@@ -828,9 +790,15 @@ fn collect_and_pull_dir(
|
|||||||
stats: &mut SyncStats,
|
stats: &mut SyncStats,
|
||||||
vault_relative_paths: &mut HashSet<String>,
|
vault_relative_paths: &mut HashSet<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
||||||
|
|
||||||
for child in children {
|
for child in children {
|
||||||
|
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
|
||||||
|
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
validate_node_name(&child.name)?;
|
validate_node_name(&child.name)?;
|
||||||
|
|
||||||
let child_rel = if rel_prefix.is_empty() {
|
let child_rel = if rel_prefix.is_empty() {
|
||||||
|
|||||||
+1
-1
@@ -341,7 +341,7 @@ mod tests {
|
|||||||
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
||||||
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
|
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
|
||||||
assert_eq!(report.total_files, 1);
|
assert_eq!(report.total_files, 1);
|
||||||
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
|
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
|
||||||
assert_eq!(report.total_chunks, 2);
|
assert_eq!(report.total_chunks, 2);
|
||||||
assert_eq!(report.corrupted_chunks, 0);
|
assert_eq!(report.corrupted_chunks, 0);
|
||||||
assert_eq!(report.orphan_nodes, 0);
|
assert_eq!(report.orphan_nodes, 0);
|
||||||
|
|||||||
@@ -562,6 +562,7 @@ impl SanctumFs {
|
|||||||
|
|
||||||
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
|
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
|
||||||
validate_path_safety(name)?;
|
validate_path_safety(name)?;
|
||||||
|
crate::pathutil::validate_node_name(name).map_err(|_| FsError::Forbidden)?;
|
||||||
self.db
|
self.db
|
||||||
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -572,6 +573,7 @@ impl SanctumFs {
|
|||||||
|
|
||||||
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
|
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
|
||||||
validate_path_safety(new_name)?;
|
validate_path_safety(new_name)?;
|
||||||
|
crate::pathutil::validate_node_name(new_name).map_err(|_| FsError::Forbidden)?;
|
||||||
self.db
|
self.db
|
||||||
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
||||||
.map_err(|_| FsError::GeneralFailure)
|
.map_err(|_| FsError::GeneralFailure)
|
||||||
@@ -941,6 +943,11 @@ impl DavFileSystem for SanctumFs {
|
|||||||
return Err(FsError::NotImplemented);
|
return Err(FsError::NotImplemented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-02: Schutz der Trägerdatei: Kopieren der Trägerdatei (Quelle) ist strikt verboten!
|
||||||
|
if self.carrier_node_id == Some(node.id) {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
||||||
|
|
||||||
if self.anti_leak && is_leak_file(to_name) {
|
if self.anti_leak && is_leak_file(to_name) {
|
||||||
|
|||||||
+136
-4
@@ -2,6 +2,8 @@ use std::process::Command;
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
|
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
|
||||||
pub fn find_next_available_drive() -> Result<char> {
|
pub fn find_next_available_drive() -> Result<char> {
|
||||||
@@ -524,6 +526,37 @@ static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[repr(C)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct NETRESOURCEW {
|
||||||
|
dwScope: u32,
|
||||||
|
dwType: u32,
|
||||||
|
dwDisplayType: u32,
|
||||||
|
dwUsage: u32,
|
||||||
|
lpLocalName: *mut u16,
|
||||||
|
lpRemoteName: *mut u16,
|
||||||
|
lpComment: *mut u16,
|
||||||
|
lpProvider: *mut u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[link(name = "mpr")]
|
||||||
|
extern "system" {
|
||||||
|
fn WNetAddConnection2W(
|
||||||
|
lpNetResource: *const NETRESOURCEW,
|
||||||
|
lpPassword: *const u16,
|
||||||
|
lpUserName: *const u16,
|
||||||
|
dwFlags: u32,
|
||||||
|
) -> u32;
|
||||||
|
|
||||||
|
fn WNetCancelConnection2W(
|
||||||
|
lpName: *const u16,
|
||||||
|
dwFlags: u32,
|
||||||
|
fForce: i32,
|
||||||
|
) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||||
const CTRL_C_EVENT: u32 = 0;
|
const CTRL_C_EVENT: u32 = 0;
|
||||||
@@ -534,13 +567,17 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
|||||||
|
|
||||||
match ctrl_type {
|
match ctrl_type {
|
||||||
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
|
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
|
||||||
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
|
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen (in-process)
|
||||||
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||||
if let Some(dl) = *guard {
|
if let Some(dl) = *guard {
|
||||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||||
let _ = std::process::Command::new("net")
|
let wide_drive: Vec<u16> = drive_str
|
||||||
.args(["use", &drive_str, "/delete", "/y"])
|
.encode_utf16()
|
||||||
.output();
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
unsafe {
|
||||||
|
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
|
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
|
||||||
@@ -681,6 +718,101 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1094,10 +1094,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
|
|
||||||
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
||||||
let session_token = "deadbeefcafebabe0123456789abcdef";
|
let session_token = "deadbeefcafebabe0123456789abcdef";
|
||||||
let token_path_prefix = format!("/{}", session_token);
|
|
||||||
|
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.strip_prefix(token_path_prefix.clone())
|
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
.locksystem(FakeLs::new())
|
.locksystem(FakeLs::new())
|
||||||
.build_handler();
|
.build_handler();
|
||||||
@@ -1109,11 +1107,11 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||||
listener,
|
listener,
|
||||||
dav_server,
|
dav_server,
|
||||||
token_path_prefix.clone(),
|
session_token.to_string(),
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
|
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 401 Unauthorized mit WWW-Authenticate
|
||||||
{
|
{
|
||||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||||
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
|
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
|
||||||
@@ -1122,8 +1120,13 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
stream.read_to_end(&mut resp).await.unwrap();
|
stream.read_to_end(&mut resp).await.unwrap();
|
||||||
let resp_str = String::from_utf8_lossy(&resp);
|
let resp_str = String::from_utf8_lossy(&resp);
|
||||||
assert!(
|
assert!(
|
||||||
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
|
resp_str.starts_with("HTTP/1.1 401 Unauthorized"),
|
||||||
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
|
"Anfrage ohne Session-Token muss mit 401 Unauthorized abgewiesen werden, erhalten:\n{}",
|
||||||
|
resp_str
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp_str.to_lowercase().contains("www-authenticate: basic realm=\"sanctum\""),
|
||||||
|
"401-Antwort muss WWW-Authenticate Header enthalten, erhalten:\n{}",
|
||||||
resp_str
|
resp_str
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1143,7 +1146,7 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
|
// 5. Legitimer Zugriff: Korrektes Session-Token via Path-Prefix Fallback -> 200 OK
|
||||||
{
|
{
|
||||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||||
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
|
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
|
||||||
@@ -1162,6 +1165,23 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Legitimer Zugriff: HTTP Basic Auth (R-05) -> 200 OK
|
||||||
|
{
|
||||||
|
use base64::Engine;
|
||||||
|
let auth_hdr = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token)));
|
||||||
|
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let req = format!("OPTIONS / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAuthorization: {}\r\nConnection: close\r\n\r\n", addr.port(), auth_hdr);
|
||||||
|
stream.write_all(req.as_bytes()).await.unwrap();
|
||||||
|
let mut resp = Vec::new();
|
||||||
|
stream.read_to_end(&mut resp).await.unwrap();
|
||||||
|
let resp_str = String::from_utf8_lossy(&resp);
|
||||||
|
assert!(
|
||||||
|
resp_str.starts_with("HTTP/1.1 200 OK"),
|
||||||
|
"Legitime OPTIONS-Anfrage mit Basic Auth muss 200 OK liefern, erhalten:\n{}",
|
||||||
|
resp_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Server ordnungsgemäß beenden & aufräumen
|
// Server ordnungsgemäß beenden & aufräumen
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||||
MIN_TIME_COST,
|
MIN_TIME_COST,
|
||||||
@@ -82,3 +82,34 @@ fn test_wal_and_shm_cleanup_on_close() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&container_path);
|
let _ = std::fs::remove_file(&container_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mount_security_multi_auth_and_no_token_in_url() {
|
||||||
|
use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix};
|
||||||
|
|
||||||
|
let session_token = "4f8a12bc90de45f187a23456789abcde";
|
||||||
|
let port = 8443;
|
||||||
|
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||||
|
|
||||||
|
// R-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
|
||||||
|
assert!(!remote_url.contains(session_token), "Remote URL darf niemals das Session-Token enthalten");
|
||||||
|
|
||||||
|
// 1. Basic Auth Prüfung
|
||||||
|
use base64::Engine;
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))
|
||||||
|
);
|
||||||
|
assert!(check_basic_auth(&auth_header, session_token));
|
||||||
|
|
||||||
|
// 2. Token Header Prüfung
|
||||||
|
let mut headers = hyper::HeaderMap::new();
|
||||||
|
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
||||||
|
assert!(check_token_header(&headers, session_token));
|
||||||
|
|
||||||
|
// 3. Path-Prefix Fallback
|
||||||
|
let prefix = format!("/{}", session_token);
|
||||||
|
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
|
||||||
|
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
|
||||||
|
assert_eq!(stripped.path(), "/test.txt");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use sanctum::sync::validate_node_name;
|
use sanctum::sync::validate_node_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_node_name_rejections() {
|
fn test_validate_node_name_rejections() {
|
||||||
@@ -60,3 +60,40 @@ fn test_validate_node_name_accepted() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -109,5 +109,71 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
|
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");
|
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);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user