From 1cdb30147b3c4ecb7f46db39f530a85113ad590d Mon Sep 17 00:00:00 2001 From: harald Date: Fri, 18 Sep 2026 19:49:38 +0200 Subject: [PATCH] =?UTF-8?q?release:=20v0.7.1=20=E2=80=94=20Security-Patch?= =?UTF-8?q?=20(R-01=20bis=20R-06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/release.yaml | 30 +++ CHANGELOG.md | 29 +++ Cargo.lock | 3 +- Cargo.toml | 3 +- LEGAL.md | 8 +- README.md | 2 +- scripts/package-release-linux.ps1 | 9 + scripts/package-release.ps1 | 6 +- scripts/publish-release.ps1 | 6 + src/crypto.rs | 3 +- src/lib.rs | 1 + src/main.rs | 8 +- src/mount.rs | 277 +++++++++++++++++--------- src/pathutil.rs | 65 ++++++ src/recovery.rs | 2 +- src/storage.rs | 12 +- src/sync.rs | 50 +---- src/verify.rs | 2 +- src/vfs.rs | 7 + src/windows.rs | 140 ++++++++++++- tests/integration_test.rs | 34 +++- tests/mount_security_test.rs | 33 ++- tests/path_traversal_test.rs | 39 +++- tests/sync_carrier_protection_test.rs | 68 ++++++- 24 files changed, 663 insertions(+), 174 deletions(-) create mode 100644 src/pathutil.rs diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index f33430a..1ec3fbb 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -51,6 +51,35 @@ jobs: echo "ZIP_FILE=$ZipFile" >> $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 uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') @@ -58,6 +87,7 @@ jobs: files: | dist/*.zip dist/SHA256SUMS.txt + dist/SHA256SUMS.txt.minisig target/release/sanctum.exe body_path: CHANGELOG.md draft: false diff --git a/CHANGELOG.md b/CHANGELOG.md index de135f7..4132d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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:/` 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 `), `X-Sanctum-Token`-Header und Pfad-Präfix-Fallback (`//`). 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 diff --git a/Cargo.lock b/Cargo.lock index 79663d6..e829271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,11 +1457,12 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "sanctum" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes-gcm", "anyhow", "argon2", + "base64", "bip39", "bytes", "clap", diff --git a/Cargo.toml b/Cargo.toml index c4c2e9a..8d69146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanctum" -version = "0.7.0" +version = "0.7.1" edition = "2021" authors = ["Harald Pansi ", "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"] } diff --git a/LEGAL.md b/LEGAL.md index 6d67746..2ef86d0 100644 --- a/LEGAL.md +++ b/LEGAL.md @@ -35,8 +35,8 @@ Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren > [!WARNING] > **Wichtiger Sicherheitshinweis & Haftungsausschluss für Anwender**: > - **Schutzziel**: Die Dual-Vault-Funktion dient dem Schutz vor neugierigen Blicken (Schulterblick), Diebstahl oder beiläufigem Zwang im Alltag, indem ein plausibler Alibi-Tresor mit unkritischen Daten vorgezeigt werden kann. -> - **KEINE forensische "Plausible Deniability"**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält. -> - **Kein Schutz vor behördlicher Beschlagnahme**: Sanctum garantiert **keine** juristisch oder forensisch unnachweisbare Abstreitbarkeit ("Plausible Deniability") und ist **nicht** als RIPA-49-Schutzschild konzipiert. +> - **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. @@ -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): -* **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. diff --git a/README.md b/README.md index cc46ea2..ecf7a0e 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu - **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. - **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 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**: diff --git a/scripts/package-release-linux.ps1 b/scripts/package-release-linux.ps1 index d18b840..e05a5f8 100644 --- a/scripts/package-release-linux.ps1 +++ b/scripts/package-release-linux.ps1 @@ -118,8 +118,17 @@ if (Test-Path $KeyFile) { & $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) diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 1b54cda..af0dc2b 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -118,10 +118,12 @@ if (Test-Path $KeyFile) { exit 1 } } 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 { - 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 diff --git a/scripts/publish-release.ps1 b/scripts/publish-release.ps1 index 9b2e2cc..509935a 100644 --- a/scripts/publish-release.ps1 +++ b/scripts/publish-release.ps1 @@ -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) { diff --git a/src/crypto.rs b/src/crypto.rs index a2c16ae..6cb4d6e 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -241,8 +241,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; 12], [u8; 16], [u8; 16]) { let mut wrapped_dek = vec![0u8; 72]; let mut nonce = [0u8; 12]; diff --git a/src/lib.rs b/src/lib.rs index 0c27f68..1af77a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod carrier; pub mod crypto; pub mod mount; +pub mod pathutil; pub mod platform; pub mod recovery; pub mod storage; diff --git a/src/main.rs b/src/main.rs index a3019f0..a97c8a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,10 +45,8 @@ enum Commands { #[arg(long, default_value_t = false)] with_hidden: bool, - /// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat) - /// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit - /// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion - /// in forensischen Entropie-Scannern zu verhindern. + /// Dateiname der Alibi-Trägerdatei im Standard-Tresor (Standard: system_backup.dat). + /// Hinweis: Die Datei dient als Träger für den zweiten Tresor und ist forensisch erkennbar. #[arg(long, default_value = "system_backup.dat")] carrier_name: String, @@ -340,6 +338,8 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result>> Ok(Some(Zeroizing::new(key.to_string()))) } } else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") { + // R-06: Umgebungsvariable nach dem Einlesen sofort aus dem Speicher des Prozesses entfernen + std::env::remove_var("SANCTUM_RECOVERY_KEY"); let trimmed = env_key.trim(); if !trimmed.is_empty() { println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ")); diff --git a/src/mount.rs b/src/mount.rs index 7686e86..eae33f5 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -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 : /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,25 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> { } } -/// Bindet ein Windows-Netzlaufwerk via `net use : http://127.0.0.1:// /persistent:no` ein -/// bzw. unter Unix via `gio mount dav://127.0.0.1://`. -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:/`. +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 dav_url = format!("dav://127.0.0.1:{}/", port); let _ = Command::new("gio") .args(["mount", &dav_url]) .output(); - let _ = drive_str; + 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 +89,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; @@ -237,7 +172,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( @@ -251,7 +185,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(); @@ -302,12 +235,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); @@ -416,12 +349,12 @@ pub async fn mount_container( println!(" • Modus: WebDAV Userland-VFS"); } } - println!(" • WebDAV-URL: http://127.0.0.1:{}// (lokal geschützt)", bound_port); + 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); + println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port); if let Some(mp) = mount_point { - println!(" • davfs2: mount -t davfs http://127.0.0.1:{}// {}", bound_port, mp.display()); + println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display()); } } 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). 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 / aus der Request-URI heraus (Defense-in-Depth Fallback). +pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option { + 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 { + 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( listener: TcpListener, dav_server: DavHandler, - token_path_prefix: String, + session_token: String, mut shutdown_rx: watch::Receiver, ) { let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS)); + let token_prefix = format!("/{}", session_token); loop { tokio::select! { @@ -716,13 +723,15 @@ 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(); + let expected_prefix = token_prefix.clone(); tokio::spawn(async move { 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 token = expected_token.clone(); let prefix = expected_prefix.clone(); async move { // 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); } - // 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF) - let path = req.uri().path(); - if !path.starts_with(&prefix) { - debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path); - let res = hyper::Response::builder() - .status(hyper::StatusCode::FORBIDDEN) - .body(dav_server::body::Body::empty()) - .unwrap(); - return Ok::<_, Infallible>(res); + // 2. R-05: Multi-Auth Middleware + // a) HTTP Basic Auth (Authorization: Basic ...) + // b) Header X-Sanctum-Token + // c) Pfad-Präfix Fallback (//...) + 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 { + 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) @@ -767,7 +797,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); } }); @@ -806,6 +835,60 @@ 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_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()); + } } diff --git a/src/pathutil.rs b/src/pathutil.rs new file mode 100644 index 0000000..bdb8e38 --- /dev/null +++ b/src/pathutil.rs @@ -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()); + } +} diff --git a/src/recovery.rs b/src/recovery.rs index 31a487d..7fd3965 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -314,7 +314,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 { diff --git a/src/storage.rs b/src/storage.rs index 26c4337..3fcdd5a 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -639,7 +639,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) @@ -720,7 +720,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( @@ -758,7 +758,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], @@ -1191,6 +1191,7 @@ impl Database { is_dir: bool, dek: &[u8; 32], ) -> Result { + crate::pathutil::validate_node_name(name)?; let now = current_timestamp(); let conn = self.conn.lock().unwrap(); @@ -1299,6 +1300,7 @@ impl Database { 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 { @@ -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 { let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); let dummy_params_json = serde_json::to_string(&KdfParams::default())?; @@ -1910,7 +1912,7 @@ mod tests { params![hidden_file.id], |r| r.get(0), ).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.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); diff --git a/src/sync.rs b/src/sync.rs index 887ebe0..618ebb9 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -13,47 +13,7 @@ use crate::storage::{Database, NodeRecord}; use crate::ui; use crate::vfs::is_leak_file; -/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08). -pub fn validate_node_name(name: &str) -> Result<()> { - if name.is_empty() { - bail!("Dateiname darf nicht leer sein."); - } - if name == "." || name == ".." { - bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name); - } - if name.contains('/') || name.contains('\\') || name.contains('\0') { - bail!( - "Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'", - name - ); - } - for c in name.chars() { - if (c as u32) < 0x20 { - bail!("Dateiname enthält Steuerzeichen: '{}'", name); - } - } - - // Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9) - let base_name = if let Some(dot_idx) = name.find('.') { - &name[..dot_idx] - } else { - name - }; - let base_upper = base_name.to_ascii_uppercase(); - let reserved = [ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - ]; - if reserved.contains(&base_upper.as_str()) { - bail!( - "Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.", - name - ); - } - - Ok(()) -} +pub use crate::pathutil::validate_node_name; /// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10). fn calc_local_file_sha256(path: &Path) -> Result { @@ -288,6 +248,7 @@ pub fn sync_single_file_to_vault( let existing_node = children.into_iter().find(|c| c.name == file_name); if let Some(ref node) = existing_node { + db.assert_not_carrier(node.id)?; if node.is_dir { bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name); } @@ -370,6 +331,7 @@ pub fn sync_single_file_to_host( checksum: bool, dry_run: bool, ) -> Result { + db.assert_not_carrier(node.id)?; validate_node_name(&node.name)?; if local_path.exists() { @@ -828,9 +790,15 @@ fn collect_and_pull_dir( stats: &mut SyncStats, vault_relative_paths: &mut HashSet, ) -> Result<()> { + let carrier_id = db.find_carrier_node_id()?.unwrap_or(0); let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?; for child in children { + // R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen + if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) { + continue; + } + validate_node_name(&child.name)?; let child_rel = if rel_prefix.is_empty() { diff --git a/src/verify.rs b/src/verify.rs index bddd091..ffe2a04 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -341,7 +341,7 @@ mod tests { let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); 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); diff --git a/src/vfs.rs b/src/vfs.rs index e7ae55c..e13f6c5 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -562,6 +562,7 @@ impl SanctumFs { fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { 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 +573,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) @@ -941,6 +943,11 @@ impl DavFileSystem for SanctumFs { 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) { diff --git a/src/windows.rs b/src/windows.rs index 43db31c..7e1f501 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -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 { @@ -524,6 +526,37 @@ static CONSOLE_CTRL_TX: std::sync::Mutex>> #[cfg(windows)] static CONSOLE_CTRL_DRIVE: std::sync::Mutex> = 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 +567,17 @@ 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 = 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 +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 = 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 = remote_url.encode_utf16().chain(std::iter::once(0)).collect(); + let username: Vec = "sanctum".encode_utf16().chain(std::iter::once(0)).collect(); + let password: Vec = 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 = 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::*; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 0c50cbb..64f2035 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 session_token = "deadbeefcafebabe0123456789abcdef"; - let token_path_prefix = format!("/{}", session_token); let dav_server = DavHandler::builder() - .strip_prefix(token_path_prefix.clone()) .filesystem(Box::new(fs)) .locksystem(FakeLs::new()) .build_handler(); @@ -1109,11 +1107,11 @@ async fn test_webdav_loopback_session_token_and_host_validation() { let server_handle = tokio::spawn(serve_webdav_loop( listener, dav_server, - token_path_prefix.clone(), + session_token.to_string(), 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 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(); let resp_str = String::from_utf8_lossy(&resp); assert!( - resp_str.starts_with("HTTP/1.1 403 Forbidden"), - "Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}", + resp_str.starts_with("HTTP/1.1 401 Unauthorized"), + "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 ); } @@ -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 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 let _ = shutdown_tx.send(true); let _ = server_handle.await; diff --git a/tests/mount_security_test.rs b/tests/mount_security_test.rs index 7cab84b..51626f1 100644 --- a/tests/mount_security_test.rs +++ b/tests/mount_security_test.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::PathBuf; use sanctum::crypto::{ derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST, @@ -82,3 +82,34 @@ fn test_wal_and_shm_cleanup_on_close() { 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"); +} diff --git a/tests/path_traversal_test.rs b/tests/path_traversal_test.rs index 9eea074..e6c0699 100644 --- a/tests/path_traversal_test.rs +++ b/tests/path_traversal_test.rs @@ -1,4 +1,4 @@ -use sanctum::sync::validate_node_name; +use sanctum::sync::validate_node_name; #[test] 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); +} diff --git a/tests/sync_carrier_protection_test.rs b/tests/sync_carrier_protection_test.rs index 05383b7..5104b69 100644 --- a/tests/sync_carrier_protection_test.rs +++ b/tests/sync_carrier_protection_test.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashSet; use std::path::PathBuf; use rand::rngs::OsRng; 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(); 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); }