diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 1ec3fbb..c46d44d 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -6,16 +6,18 @@ on: - 'v*' jobs: + # SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets build: - name: Build & Release (Windows x86_64) + name: Build & Test (Windows x86_64) runs-on: windows-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned with: + toolchain: "1.85.0" targets: x86_64-pc-windows-msvc - name: Run Tests @@ -47,21 +49,54 @@ jobs: @("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8 - echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT - - name: Sign Checksums with Minisign + - name: Upload Build Artifacts + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: dist-artifacts + path: dist/ + + # SA-02, SA-03 & SA-04: Dedizierter Signatur- & Release-Job mit isolierten Secrets und Prüfsummen-Verifikation + sign-and-release: + name: Sign & Release + needs: [build] + runs-on: windows-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Download Build Artifacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: dist-artifacts + path: dist + + - name: Verify and Sign Checksums with Minisign shell: pwsh env: MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} run: | + # SA-02: Secret-Isolation — MINISIGN_SECRET_KEY ist ausschließlich in diesem dedizierten Signier-Job verfügbar if (-not $env:MINISIGN_SECRET_KEY) { Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten." exit 1 } + + # SA-03: Pinned Download-Integrität für minisign.exe mit zwingender SHA-256 Validierung + $MinisignUrl = "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip" + $ExpectedMinisignHash = "b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031" + if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) { - Invoke-WebRequest -Uri "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip" -OutFile "minisign.zip" + Write-Host "Lade gepinnten Minisign-Release herunter: $MinisignUrl" + Invoke-WebRequest -Uri $MinisignUrl -OutFile "minisign.zip" + + $ActualHash = (Get-FileHash -Path "minisign.zip" -Algorithm SHA256).Hash.ToLower() + if ($ActualHash -ne $ExpectedMinisignHash) { + Write-Error "KRITISCHER SICHERHEITSFEHLER: Minisign SHA-256 Prüfsumme ungültig!`nErwartet: $ExpectedMinisignHash`nErhalten: $ActualHash`nAbbruch." + exit 1 + } + Write-Host "Minisign SHA-256 Integrität erfolgreich verifiziert ($ActualHash)." + Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin" $MinisignExe = "minisign-bin/minisign-win64/minisign.exe" } else { @@ -81,14 +116,12 @@ jobs: } - name: Create Gitea Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 with: files: | dist/*.zip dist/SHA256SUMS.txt dist/SHA256SUMS.txt.minisig - target/release/sanctum.exe body_path: CHANGELOG.md draft: false prerelease: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 4132d2e..e0c9a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). +## [0.7.2] - 2026-09-18 + +### Security Audit Remediation Release (Findings SA-01 to SA-07) +Dieses Release behebt alle sieben identifizierten Schwachstellen und Härtungsanforderungen (SA-01 bis SA-07) aus dem Security-Audit von v0.7.1. + +#### Security & Architecture +- **SA-01: Container-DoS / KDF-Amplification Schutz**: + - Pre-KDF strukturelle Validierung in `Database::open`, `read_slots`, `ContainerMeta::authenticate` und `recovery::to_meta`. + - Striktes Limit: Maximal 2 Slots (`slots.len() <= 2`), nur Slot-IDs 0 und 1 zulässig, Erkennung von Duplikaten, Slot 0 ist Pflicht. + - Strikte Validierung aller BLOB-Längen (Salt: 16 B, Nonce: 12 B, Tag: 16 B, Wrapped DEK: 32/40 B für Slot 0, 32/64/72 B für Slot 1) und KDF-Parametergrenzen vor jeder Argon2id-Ableitung (`derive_kek`). +- **SA-02: Release-Signierung entkoppelt & CI-Secrets isoliert**: + - Trennung des CI/CD-Release-Workflows (`release.yaml`) in separate `build`- und `sign-and-release`-Jobs. + - Das Secret `MINISIGN_SECRET_KEY` ist strikt auf den nachgelagerten Signatur-Job isoliert und für Build-/Test-Runner unzugänglich. +- **SA-03: Pinned Download-Integrität für `minisign.exe`**: + - SHA-256 Integritätsprüfung des offiziellen Minisign v0.11 Downloads (`b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031`) im CI-Workflow vor dem Entpacken und Ausführen (Fail-Closed). +- **SA-04: Immutable Action-Pinning & Toolchain-Pinning**: + - Alle externen GitHub/Gitea-Actions im Release-Workflow auf unveränderliche Commit-SHAs (`@`) fixiert. + - Rust-Toolchain explizit auf Version `1.85.0` gepinnt. +- **SA-05: Session-Tokens vollständig aus URIs verbannt**: + - Eingehende WebDAV-Requests, deren URI das Session-Token in Pfad oder Query-String enthalten (`//...` oder `?token=...`), werden sofort mit `403 Forbidden` abgewiesen. + - Authentifizierung wird strikt über HTTP-Header (`Authorization: Basic ...` oder `X-Sanctum-Token: ...`) erzwungen. +- **SA-06: Constant-Time Token-Vergleich**: + - Integration von `subtle::ConstantTimeEq` für zeitinvariante Vergleiche von Basic-Auth-Credentials und Session-Tokens zum Schutz vor Timing-Seitenkanalangriffen. +- **SA-07: Dokumentations-Klarstellung: Logische vs. Physische Datenlöschung**: + - Präzisierung im `README.md` und Code-Docstrings bzgl. der Grenzen des kryptografischen Chunk-Shreddings: Auf Flash-Medien (SSDs/NVMe) können Wear-Leveling und FTL-Blöcke physisch persistieren. Empfehlung zur Nutzung von Full-Disk-Encryption (FDE). + ## [0.7.1] - 2026-09-18 ### Security Patch Release (Closing Gaps R-01 to R-06) diff --git a/Cargo.lock b/Cargo.lock index e829271..79a6778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "sanctum" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes-gcm", "anyhow", @@ -1483,6 +1483,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tempfile", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 8d69146..b3f2157 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanctum" -version = "0.7.1" +version = "0.7.2" edition = "2021" authors = ["Harald Pansi ", "Sanctum Engineering Team"] description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" @@ -42,6 +42,7 @@ ureq = { version = "2.10", default-features = false, features = ["json", "tls"] sha2 = "0.10" semver = "1.0" minisign-verify = "0.2" +subtle = "2.6" tempfile = "3" [target.'cfg(windows)'.dependencies] diff --git a/README.md b/README.md index ecf7a0e..acea746 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu 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**: - Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben. +- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**: + Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene. + *Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen. --- diff --git a/src/carrier.rs b/src/carrier.rs index ed84ecd..e56cc40 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -10,8 +10,8 @@ use bytes::{Buf, Bytes, BytesMut}; use dav_server::{ davpath::DavPath, fs::{ - DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, - OpenOptions, ReadDirMeta, + DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, OpenOptions, + ReadDirMeta, }, }; use futures_util::stream; @@ -624,9 +624,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_dir); - inner - .save_manifest() - .map_err(|_| FsError::GeneralFailure)?; + inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; Ok(()) }) @@ -659,9 +657,7 @@ impl DavFileSystem for CarrierFs { } inner.manifest.inodes.remove(&node.id); - inner - .save_manifest() - .map_err(|_| FsError::GeneralFailure)?; + inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; Ok(()) }) @@ -690,9 +686,7 @@ impl DavFileSystem for CarrierFs { } inner.manifest.inodes.remove(&node.id); - inner - .save_manifest() - .map_err(|_| FsError::GeneralFailure)?; + inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; Ok(()) }) @@ -748,9 +742,7 @@ impl DavFileSystem for CarrierFs { inode.modified_at = now; } - inner - .save_manifest() - .map_err(|_| FsError::GeneralFailure)?; + inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; Ok(()) }) @@ -1011,9 +1003,7 @@ impl DavFile for CarrierFile { inode.modified_at = now; } - inner - .save_manifest() - .map_err(|_| FsError::GeneralFailure)?; + inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; self.meta.size = self.file_size; self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); @@ -1025,7 +1015,10 @@ impl DavFile for CarrierFile { impl Drop for CarrierFile { fn drop(&mut self) { if let Err(e) = self.flush_cached_block() { - tracing::warn!("Fehler beim automatischen Flush im CarrierFile::drop: {:?}", e); + tracing::warn!( + "Fehler beim automatischen Flush im CarrierFile::drop: {:?}", + e + ); } if let Some((_, ref mut data, _)) = self.cached_block { data.zeroize(); diff --git a/src/crypto.rs b/src/crypto.rs index 6cb4d6e..82c3ace 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -101,11 +101,7 @@ pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> { } /// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab. -pub fn derive_kek( - password: &str, - salt: &[u8], - params: &KdfParams, -) -> Result> { +pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result> { validate_kdf_params(params)?; let argon2_params = Params::new( @@ -145,10 +141,7 @@ pub fn generate_salt() -> [u8; 16] { } /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM. -pub fn wrap_key_payload( - kek: &[u8; 32], - payload: &[u8], -) -> Result<(Vec, [u8; 12], [u8; 16])> { +pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec, [u8; 12], [u8; 16])> { let cipher = Aes256Gcm::new_from_slice(kek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; @@ -183,17 +176,18 @@ pub fn unwrap_key_payload( let mut buffer = wrapped_payload.to_vec(); cipher .decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag) - .map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?; + .map_err(|_| { + anyhow::anyhow!( + "Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)" + ) + })?; Ok(Zeroizing::new(buffer)) } /// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM. /// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. -pub fn wrap_dek( - kek: &[u8; 32], - dek: &[u8; 32], -) -> Result<(Vec, [u8; 12], [u8; 16])> { +pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec, [u8; 12], [u8; 16])> { wrap_key_payload(kek, dek) } @@ -206,7 +200,10 @@ pub fn unwrap_dek( ) -> Result> { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?; if payload.len() < 32 { - bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len()); + bail!( + "Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", + payload.len() + ); } let mut dek = Zeroizing::new([0u8; 32]); @@ -265,7 +262,8 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] { aad } -static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); /// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10). pub fn set_allow_legacy_names(allow: bool) { @@ -289,7 +287,11 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String { }; let mut buffer = name.as_bytes().to_vec(); let aad = build_name_aad(parent_id); - let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) { + let tag = match cipher.encrypt_in_place_detached( + Nonce::from_slice(&nonce_bytes), + &aad, + &mut buffer, + ) { Ok(t) => t, Err(_) => return String::new(), }; @@ -303,7 +305,12 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String { /// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM. /// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback /// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10). -pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option { +pub fn decrypt_node_name_ext( + dek: &[u8; 32], + parent_id: i64, + stored: &str, + allow_legacy: bool, +) -> Option { // Abwärtskompatibilität für alte v0.2.0 $h$$$ Namen if let Some(rest) = stored.strip_prefix("$h$") { let parts: Vec<&str> = rest.split('$').collect(); @@ -423,7 +430,11 @@ pub fn levenshtein_distance(a: &str, b: &str) -> usize { for i in 1..=m { for j in 1..=n { - let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; + let cost = if a_chars[i - 1] == b_chars[j - 1] { + 0 + } else { + 1 + }; dp[i][j] = (dp[i - 1][j] + 1) .min(dp[i][j - 1] + 1) .min(dp[i - 1][j - 1] + cost); @@ -471,10 +482,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec { }) .collect(); - cleaned - .split_whitespace() - .map(|s| s.to_string()) - .collect() + cleaned.split_whitespace().map(|s| s.to_string()).collect() } /// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK. @@ -500,9 +508,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result> { if !word_list.contains(&word.as_str()) { let suggestion = suggest_bip39_word(word); let msg = if let Some(sug) = suggestion { - format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug) + format!( + "Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", + idx + 1, + word, + sug + ) } else { - format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word) + format!( + "Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", + idx + 1, + word + ) }; invalid_words.push(msg); } @@ -620,7 +637,11 @@ pub fn decrypt_chunk( let mut buffer = ciphertext.to_vec(); cipher .decrypt_in_place_detached(nonce, &aad, &mut buffer, tag) - .map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?; + .map_err(|_| { + anyhow::anyhow!( + "Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)" + ) + })?; if format_version >= FORMAT_VERSION_V2 { if buffer.is_empty() { @@ -654,7 +675,6 @@ pub fn decrypt_chunk( } } - #[cfg(test)] mod tests { use super::*; @@ -700,26 +720,55 @@ mod tests { encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap(); // Reguläre Entschlüsselung (v2) - let decrypted = - decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); + let decrypted = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ciphertext, + &nonce, + &tag, + FORMAT_VERSION_V2, + ) + .unwrap(); assert_eq!(decrypted, plaintext); // Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben) - let swap_node_err = - decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2); + let swap_node_err = decrypt_chunk( + &dek, + 99i64, + chunk_index, + &ciphertext, + &nonce, + &tag, + FORMAT_VERSION_V2, + ); assert!(swap_node_err.is_err()); // Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben) - let swap_idx_err = - decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2); + let swap_idx_err = decrypt_chunk( + &dek, + node_id, + 1u32, + &ciphertext, + &nonce, + &tag, + FORMAT_VERSION_V2, + ); assert!(swap_idx_err.is_err()); // Manipulation des Ciphertexts let mut tampered_ct = ciphertext.clone(); tampered_ct[0] ^= 0x01; - assert!( - decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err() - ); + assert!(decrypt_chunk( + &dek, + node_id, + chunk_index, + &tampered_ct, + &nonce, + &tag, + FORMAT_VERSION_V2 + ) + .is_err()); } #[test] @@ -742,8 +791,16 @@ mod tests { plaintext.len() ); - let decrypted = - decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); + let decrypted = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ciphertext, + &nonce, + &tag, + FORMAT_VERSION_V2, + ) + .unwrap(); assert_eq!(decrypted, plaintext); } @@ -754,8 +811,7 @@ mod tests { let mut random_bytes = vec![0u8; 1000]; OsRng.fill_bytes(&mut random_bytes); - let (ct, nonce, tag) = - encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap(); + let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap(); // Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert assert_eq!(ct.len(), random_bytes.len() + 1); @@ -775,8 +831,16 @@ mod tests { encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap(); assert_eq!(ciphertext.len(), plaintext.len()); - let decrypted = - decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap(); + let decrypted = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ciphertext, + &nonce, + &tag, + FORMAT_VERSION_V1, + ) + .unwrap(); assert_eq!(decrypted, plaintext); } @@ -788,10 +852,17 @@ mod tests { assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words"); let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK"); - assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK"); + assert_eq!( + *dek, *recovered_dek, + "Recovered DEK must match original DEK" + ); // Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche) - let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n ")); + let messy_phrase = format!( + " {} \n\t {} ", + words[0..12].join(" "), + words[12..24].join(" \n ") + ); let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy"); assert_eq!(*dek, *recovered_messy); } @@ -815,9 +886,16 @@ mod tests { .collect(); // Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort let original_last = words[23].clone(); - words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() }; + words[23] = if original_last == "abandon" { + "zoo".to_string() + } else { + "abandon".to_string() + }; let corrupted_phrase = words.join(" "); - assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail"); + assert!( + mnemonic_to_dek(&corrupted_phrase).is_err(), + "Checksum check must fail" + ); } #[test] @@ -860,8 +938,14 @@ mod tests { assert_eq!(decrypted, filename); // Abwärtskompatibilität: Legacy $h$$$ Format muss weiter entschlüsselt werden - let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]); - let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name"); + let legacy_format = format!( + "$h${}${}${}", + &encrypted[0..24], + &encrypted[24..56], + &encrypted[56..] + ); + let decrypted_legacy = + decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name"); assert_eq!(decrypted_legacy, filename); // Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME") @@ -869,7 +953,11 @@ mod tests { let mut static_buf = filename.as_bytes().to_vec(); let static_nonce = [42u8; 12]; let static_tag = cipher - .encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf) + .encrypt_in_place_detached( + Nonce::from_slice(&static_nonce), + b"SANCTUM_NODE_NAME", + &mut static_buf, + ) .unwrap(); let legacy_static_format = format!( "$h${}${}${}", @@ -882,7 +970,8 @@ mod tests { // Mit aktiviertem Legacy-Flag darf es entschlüsselt werden set_allow_legacy_names(true); - let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag"); + let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format) + .expect("Decrypt legacy static AAD name with flag"); assert_eq!(decrypted_static, filename); set_allow_legacy_names(false); @@ -904,17 +993,33 @@ mod tests { let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt"); let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt"); // Gültige parent_ids entschlüsseln erfolgreich - assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt"); - assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt"); + assert_eq!( + decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), + "secrets.txt" + ); + assert_eq!( + decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), + "passwords.txt" + ); // Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20 - assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!"); - assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!"); + assert!( + decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), + "Swap in anderen Ordner muss durch AAD fehlschlagen!" + ); + assert!( + decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), + "Swap in anderen Ordner muss durch AAD fehlschlagen!" + ); } #[test] fn test_model_a_slot_payloads() { - let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 }; + let test_kdf = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap(); let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap(); let dek_0 = generate_dek(); @@ -922,7 +1027,8 @@ mod tests { let carrier_node_id = 42i64; // Slot 0 Payload: 40 Bytes - let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_0, nonce_0, tag_0) = + wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); assert_eq!(wrapped_0.len(), 40); let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap(); @@ -932,7 +1038,8 @@ mod tests { assert_eq!(recovered_cid_0, carrier_node_id); // Slot 1 Payload: 72 Bytes - let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + let (wrapped_1, nonce_1, tag_1) = + wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); assert_eq!(wrapped_1.len(), 72); let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap(); @@ -964,15 +1071,30 @@ mod tests { let nonce = Nonce::from_slice(&nonce_bytes); let mut ct = malicious_plaintext.clone(); - let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap(); + let tag = cipher + .encrypt_in_place_detached(nonce, &aad, &mut ct) + .unwrap(); let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap(); // Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift - let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2); - assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"); + let res = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct, + &nonce_bytes, + &tag_bytes, + FORMAT_VERSION_V2, + ); + assert!( + res.is_err(), + "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!" + ); let err_msg = res.err().unwrap().to_string(); - assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg); + assert!( + err_msg.contains("Decompression-Bomb Schutz ausgelöst"), + "Fehlermeldung erwartet: {}", + err_msg + ); } } - - diff --git a/src/main.rs b/src/main.rs index a97c8a9..3389594 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,18 +295,30 @@ fn parse_drive_letter(s: &str) -> Result { pub fn parse_size_string(s: &str) -> Result { let trimmed = s.trim().to_uppercase(); if let Some(num_str) = trimmed.strip_suffix("GB") { - let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?; + let n: u64 = num_str + .trim() + .parse() + .context("Ungültige Gigabyte-Angabe")?; Ok(n * 1024 * 1024 * 1024) } else if let Some(num_str) = trimmed.strip_suffix("MB") { - let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?; + let n: u64 = num_str + .trim() + .parse() + .context("Ungültige Megabyte-Angabe")?; Ok(n * 1024 * 1024) } else if let Some(num_str) = trimmed.strip_suffix("KB") { - let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?; + let n: u64 = num_str + .trim() + .parse() + .context("Ungültige Kilobyte-Angabe")?; Ok(n * 1024) } else if let Ok(n) = trimmed.parse::() { Ok(n) } else { - bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s); + bail!( + "Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", + s + ); } } @@ -319,9 +331,14 @@ pub fn parse_size_string(s: &str) -> Result { fn resolve_recovery_key(arg: Option<&str>) -> Result>> { if let Some(key) = arg { if key == "-" { - println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan("ℹ")); + println!( + " [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", + ui::cyan("ℹ") + ); let mut line = String::new(); - std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?; + std::io::stdin() + .read_line(&mut line) + .context("Fehler beim Einlesen von stdin")?; let trimmed = line.trim(); if trimmed.is_empty() { bail!("Der Notfallschlüssel von stdin darf nicht leer sein."); @@ -342,7 +359,10 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result>> 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("ℹ")); + println!( + " [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", + ui::cyan("ℹ") + ); Ok(Some(Zeroizing::new(trimmed.to_string()))) } else { Ok(None) @@ -355,8 +375,14 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result>> /// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein, /// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen. fn prompt_recovery_key_interactive() -> Result> { - println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan("ℹ")); - println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec")); + println!( + " [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", + ui::cyan("ℹ") + ); + println!( + " [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", + ui::dim("OpSec") + ); let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ") .context("Fehler beim Einlesen des Notfallschlüssels")?; if phrase.trim().is_empty() { @@ -383,8 +409,15 @@ fn handle_init( println!("└─────────────────────────────────────────────────────────────┘"); println!(" Zieldatei: {}", container_path.display()); if with_hidden { - println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei")); - println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str)); + println!( + " Modus: Dual-Vault (Modell A: {})", + ui::magenta("Carrier-Datei") + ); + println!( + " Carrier: {} ({})", + ui::cyan(carrier_name), + ui::cyan(carrier_size_str) + ); println!( " [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.", ui::yellow("Info") @@ -415,7 +448,10 @@ fn handle_init( println!(); println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───"); - println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG")); + println!( + " [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", + ui::yellow("WICHTIG") + ); let password_1 = Zeroizing::new( rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ") .context("Fehler beim Einlesen des Passworts")?, @@ -432,7 +468,12 @@ fn handle_init( } println!(); - ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab..."); + ui::step( + 1, + 4, + "🔑", + "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...", + ); let salt_0 = generate_salt(); let kdf_params_0 = KdfParams::default(); let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?; @@ -445,14 +486,24 @@ fn handle_init( let dek_0 = generate_dek(); let dek_1 = generate_dek(); - ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)..."); + ui::step( + 3, + 4, + "🔒", + "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...", + ); let carrier_node_id = 3i64; let (wrapped_dek_0, header_nonce_0, header_tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?; - ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei..."); - let db = Database::open(container_path) - .context("Konnte SQLite-Containerdatei nicht anlegen")?; + ui::step( + 4, + 4, + "📦", + "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...", + ); + let db = + Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?; let (wrapped_dek_1, header_nonce_1, header_tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?; @@ -487,7 +538,10 @@ fn handle_init( println!("└─────────────────────────────────────────────────────────────┘"); println!(); println!(" • Container: {}", container_path.display()); - println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); + println!( + " • Format: Version {} (Magic: SANCTUM\\0)", + FORMAT_VERSION + ); println!(" • Carrier-Datei:{} ({})", carrier_name, carrier_size_str); println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)"); println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault"); @@ -495,8 +549,16 @@ fn handle_init( println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen"); println!(); println!(" Befehl zum Einbinden als Netzlaufwerk:"); - println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); - println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)"); + println!( + " {}", + ui::cyan(&format!( + "sanctum mount --path \"{}\"", + container_path.display() + )) + ); + println!( + " (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)" + ); println!(); println!(" ┌─────────────────────────────────────────────────────────┐"); @@ -525,25 +587,47 @@ fn handle_init( } println!(); - ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)..."); + ui::step( + 1, + 4, + "🔑", + "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...", + ); let salt = generate_salt(); let kdf_params = KdfParams::default(); let kek = derive_kek(&password, &salt, &kdf_params) .context("KDF-Schlüsselableitung fehlgeschlagen")?; - ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG..."); + ui::step( + 2, + 4, + "🎲", + "Erzeuge kryptografisch sicheren DEK via CSPRNG...", + ); let dek = generate_dek(); ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM..."); let (wrapped_dek, header_nonce, header_tag) = wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?; - ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus..."); - let db = Database::open(container_path) - .context("Konnte SQLite-Containerdatei nicht anlegen")?; + ui::step( + 4, + 4, + "📦", + "Initialisiere SQLite-Containerstruktur & WAL-Modus...", + ); + let db = + Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?; - db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None) - .context("Fehler bei der Schema-Initialisierung")?; + db.init_schema_with_carrier( + &salt, + &kdf_params, + &wrapped_dek, + &header_nonce, + &header_tag, + None, + ) + .context("Fehler bei der Schema-Initialisierung")?; db.checkpoint() .context("Fehler beim finalen WAL-Checkpoint")?; @@ -557,13 +641,22 @@ fn handle_init( println!("└─────────────────────────────────────────────────────────────┘"); println!(); println!(" • Container: {}", container_path.display()); - println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); + println!( + " • Format: Version {} (Magic: SANCTUM\\0)", + FORMAT_VERSION + ); println!(" • KDF: Argon2id (M=64MB, T=3, P=4)"); println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)"); println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)"); println!(); println!(" Befehl zum Einbinden als Netzlaufwerk:"); - println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); + println!( + " {}", + ui::cyan(&format!( + "sanctum mount --path \"{}\"", + container_path.display() + )) + ); println!(); ui::print_recovery_phrase_card(&recovery_phrase); @@ -574,7 +667,10 @@ fn handle_init( fn handle_compact(container_path: &Path, pages: Option) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } println!("┌─────────────────────────────────────────────────────────────┐"); @@ -583,13 +679,16 @@ fn handle_compact(container_path: &Path, pages: Option) -> Result<()> { println!(" Container: {}", container_path.display()); println!(); - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; - let freelist_before = db.freelist_count() + let freelist_before = db + .freelist_count() .context("Konnte Freelist-Größe nicht ermitteln")?; - println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string())); + println!( + " • Freie Seiten in Freelist: {}", + ui::cyan(&freelist_before.to_string()) + ); if freelist_before == 0 { println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig."); @@ -598,7 +697,8 @@ fn handle_compact(container_path: &Path, pages: Option) -> Result<()> { } ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus..."); - let freed = db.incremental_vacuum(pages) + let freed = db + .incremental_vacuum(pages) .context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?; ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch..."); @@ -612,8 +712,14 @@ fn handle_compact(container_path: &Path, pages: Option) -> Result<()> { println!("│ ✔ Container erfolgreich kompaktiert! │"); println!("└─────────────────────────────────────────────────────────────┘"); println!(); - println!(" • Freigegebene Seiten: {}", ui::green(&freed.to_string())); - println!(" • Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string())); + println!( + " • Freigegebene Seiten: {}", + ui::green(&freed.to_string()) + ); + println!( + " • Verbleibende Freeseiten: {}", + ui::dim(&freelist_after.to_string()) + ); println!(); Ok(()) } @@ -632,24 +738,29 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() println!(" Container: {}", container_path.display()); println!(); - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; - let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(recovery_key)? { + let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = + resolve_recovery_key(recovery_key)? + { ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); - let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + let meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0); if detected_slot == 1 { println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault)."); println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt."); let decoy_pw = Zeroizing::new( rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ") - .context("Fehler beim Einlesen des Decoy-Passworts")? + .context("Fehler beim Einlesen des Decoy-Passworts")?, ); - let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?; + let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| { + anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.") + })?; let c_dek = decoy_auth.dek().clone(); (d, 1, Some(c_dek), db.find_carrier_node_id()?) } else { @@ -671,10 +782,17 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .read_meta() .context("Konnte Container-Header nicht lesen")?; - ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)..."); - let keys = meta - .authenticate(&old_password) - .ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?; + ui::step( + 2, + 4, + "🔑", + "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...", + ); + let keys = meta.authenticate(&old_password).ok_or_else(|| { + anyhow::anyhow!( + "Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen." + ) + })?; let d = keys.dek().clone(); let slot_id = keys.slot_id(); (d, slot_id, keys.carrier_dek(), keys.carrier_node_id()) @@ -697,14 +815,20 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() } println!(); - ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab..."); + ui::step( + 3, + 4, + "🔒", + "Generiere frisches Salt & leite neuen KEK ab...", + ); let new_salt = generate_salt(); let new_params = KdfParams::default(); let new_kek = derive_kek(&new_password, &new_salt, &new_params) .context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?; let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 { - let c_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?; + let c_dek = carrier_dek + .ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?; let c_nid = carrier_node_id.unwrap_or(0); wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid) .context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")? @@ -714,7 +838,12 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")? }; - ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus..."); + ui::step( + 4, + 4, + "💾", + "Aktualisiere Container-Header & führe Checkpoint aus...", + ); db.update_slot_keys( target_slot_id, &new_salt, @@ -774,10 +903,16 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } if output_path.exists() { - bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display()); + bail!( + "Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", + output_path.display() + ); } println!("┌─────────────────────────────────────────────────────────────┐"); @@ -787,11 +922,20 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> { println!(" Backup-Ziel: {}", output_path.display()); println!(); - ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream..."); - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + ui::step( + 1, + 2, + "📦", + "Öffne Container & initialisiere Online-Backup-Stream...", + ); + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; - ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API..."); + ui::step( + 2, + 2, + "💾", + "Kopiere Datenbankseiten konsistent via SQLite Backup API...", + ); db.online_backup(output_path) .context("Fehler beim Erstellen des Online-Backups")?; @@ -801,7 +945,9 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> { println!("└─────────────────────────────────────────────────────────────┘"); println!(); println!(" • Backup-Datei: {}", output_path.display()); - println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)"); + println!( + " • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)" + ); println!(); Ok(()) @@ -826,7 +972,12 @@ fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> { Database::restore_from_backup(backup_path, output_path) .context("Fehler bei der Container-Wiederherstellung aus dem Backup")?; - ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!"); + ui::step( + 2, + 2, + "🔍", + "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!", + ); println!(); println!("┌─────────────────────────────────────────────────────────────┐"); @@ -856,9 +1007,17 @@ fn handle_restore_header( println!(" Verwende Backup-Datei: {}", hdr.display()); ui::step(1, 2, "📦", "Lese und validiere Header-Backup..."); restore_header_backup(container_path, hdr)?; - ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!"); + ui::step( + 2, + 2, + "💾", + "Header in Container-Datenbank zurückgeschrieben!", + ); println!(); - println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")); + println!( + "{}", + ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!") + ); } else { let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? { key @@ -887,7 +1046,8 @@ fn handle_restore_header( }; let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?; - let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; + let db = + Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; let effective_slot = if slot == 0 { detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0) @@ -895,7 +1055,10 @@ fn handle_restore_header( slot }; - println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot); + println!( + " Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", + effective_slot + ); let new_password = Zeroizing::new( rpassword::prompt_password("Neues Master-Passwort festlegen: ") .context("Fehler beim Einlesen des Passworts")?, @@ -916,10 +1079,14 @@ fn handle_restore_header( println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt."); let decoy_pw = Zeroizing::new( rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ") - .context("Fehler beim Einlesen des Decoy-Passworts")? + .context("Fehler beim Einlesen des Decoy-Passworts")?, ); - let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; - let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?; + let meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; + let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| { + anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.") + })?; Some(**decoy_auth.dek()) } else { None @@ -927,10 +1094,27 @@ fn handle_restore_header( let dek_0_opt = dek_0_holder.as_ref(); ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab..."); - restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?; - ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot)); + restore_slot_from_recovery_key( + container_path, + &key_str, + &new_password, + effective_slot, + dek_0_opt, + )?; + ui::step( + 2, + 2, + "💾", + &format!( + "Header für Slot {} mit neuem Passwort neu synthetisiert!", + effective_slot + ), + ); println!(); - println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")); + println!( + "{}", + ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!") + ); } println!(); @@ -939,7 +1123,10 @@ fn handle_restore_header( fn handle_recovery_key(container_path: &Path) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } println!("┌─────────────────────────────────────────────────────────────┐"); @@ -953,9 +1140,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { .context("Fehler beim Einlesen des Passworts")?, ); - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; - let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; + let meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; let keys = meta .authenticate(&password) @@ -970,7 +1158,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { fn handle_verify(container_path: &Path, full: bool) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } println!("┌─────────────────────────────────────────────────────────────┐"); @@ -994,12 +1185,18 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { Some(keys.dek().clone()) } None => { - println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); + println!( + " {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", + ui::yellow("⚠️") + ); None } } } else { - println!(" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", ui::dim("ℹ")); + println!( + " {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", + ui::dim("ℹ") + ); None }; @@ -1026,7 +1223,10 @@ fn handle_sync( recovery_key: Option<&str>, ) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? { @@ -1042,9 +1242,10 @@ fn handle_sync( ContainerAuth::Password(Zeroizing::new(pass)) }; - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; - let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; + let meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; let (dek, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { @@ -1054,8 +1255,7 @@ fn handle_sync( (keys.dek().clone(), meta.version, keys.slot_id()) } ContainerAuth::RecoveryKey(ref phrase) => { - let dek = mnemonic_to_dek(phrase) - .context("Ungültiger 24-Wort Notfallschlüssel")?; + let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0); (dek, meta.version, v_id) } @@ -1085,34 +1285,35 @@ fn handle_sync( println!(); if dry_run { - println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]")); + println!( + " {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", + ui::yellow("[!]") + ); } println!(" • Richtung: {}", ui::cyan(dir_str)); println!(" • Quelle: {}", source); println!(" • Ziel: {}", target); if delete { - println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv")); + println!( + " • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", + ui::red("--delete aktiv") + ); } if checksum { println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)"); } if !options.exclude_patterns.is_empty() { - println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len()); + println!( + " • Filter: {} Ausschlussmuster", + options.exclude_patterns.len() + ); } println!(); println!(" {} Starte Synchronisation ...", ui::dim("[-]")); println!(); } - let stats = sanctum::sync::run_sync( - &db, - vault_id, - &dek, - version, - source, - target, - &options, - )?; + let stats = sanctum::sync::run_sync(&db, vault_id, &dek, version, source, target, &options)?; if !dry_run { let _ = db.checkpoint(); @@ -1124,27 +1325,59 @@ fn handle_sync( println!("┌─────────────────────────────────────────────────────────────┐"); println!("│ ℹ Dry-Run Simulation erfolgreich abgeschlossen │"); println!("└─────────────────────────────────────────────────────────────┘"); - println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned); - println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred)); - println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped); + println!( + " • Gescannt: {} Dateien/Objekte", + stats.files_scanned + ); + println!( + " • Zu übertragen: {} Dateien ({})", + stats.files_transferred, + ui::format_bytes(stats.bytes_transferred) + ); + println!( + " • Bereits aktuell: {} Dateien (übersprungen)", + stats.files_skipped + ); if delete { - println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted); + println!( + " • Zu löschen: {} Dateien/Ordner", + stats.files_deleted + ); } - println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow("✔")); + println!( + " • Hinweis: {} Es wurden keine Änderungen vorgenommen.", + ui::yellow("✔") + ); } else { println!("┌─────────────────────────────────────────────────────────────┐"); println!("│ ✔ Synchronisation erfolgreich abgeschlossen │"); println!("└─────────────────────────────────────────────────────────────┘"); - println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned); - println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred)); - println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped); + println!( + " • Gescannt: {} Dateien/Objekte", + stats.files_scanned + ); + println!( + " • Übertragen: {} Dateien ({})", + stats.files_transferred, + ui::format_bytes(stats.bytes_transferred) + ); + println!( + " • Übersprungen: {} Dateien (bereits aktuell)", + stats.files_skipped + ); if delete { - println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted); + println!( + " • Gelöscht: {} verwaiste Dateien/Ordner", + stats.files_deleted + ); } let secs = stats.elapsed.as_secs_f64(); let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0); let speed = if secs > 0.05 { mb / secs } else { 0.0 }; - println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed); + println!( + " • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", + secs, speed + ); } println!(); } @@ -1247,11 +1480,19 @@ async fn run() -> Result<()> { Commands::Unmount { drive } => { let drive_char = parse_drive_letter(&drive)?; let drive_str = format_drive(drive_char); - print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); + print!( + " {} Trenne Windows-Netzlaufwerk {} ... ", + ui::dim("[-]"), + drive_str + ); let _ = std::io::Write::flush(&mut std::io::stdout()); unmount_drive(drive_char)?; println!("{}", ui::green("OK")); - println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str); + println!( + "{} Laufwerk {} erfolgreich getrennt.", + ui::green("✔"), + drive_str + ); } Commands::Passwd { path, recovery_key } => { handle_passwd(&path, recovery_key.as_deref())?; @@ -1304,7 +1545,10 @@ async fn run() -> Result<()> { println!("└─────────────────────────────────────────────────────────────┘"); println!(); sanctum::windows::register_explorer_integration()?; - println!(" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!", ui::green("✔")); + println!( + " {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!", + ui::green("✔") + ); println!(" • Doppelklick: Öffnet und bindet den Container direkt als Laufwerk ein"); println!(" • Rechtsklick: Bietet Optionen für Integritätsprüfung (FSCK) und Header-Backup"); println!(); @@ -1315,10 +1559,19 @@ async fn run() -> Result<()> { println!("└─────────────────────────────────────────────────────────────┘"); println!(); sanctum::windows::unregister_explorer_integration()?; - println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔")); + println!( + " {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", + ui::green("✔") + ); println!(); } - Commands::Upgrade { check, yes, force, url, insecure_url } => { + Commands::Upgrade { + check, + yes, + force, + url, + insecure_url, + } => { let options = sanctum::upgrade::UpgradeOptions { check_only: check, yes, @@ -1333,7 +1586,6 @@ async fn run() -> Result<()> { Ok(()) } - #[tokio::main] async fn main() { // Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren @@ -1353,4 +1605,3 @@ async fn main() { std::process::exit(1); } } - diff --git a/src/mount.rs b/src/mount.rs index eae33f5..77e3a6a 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -61,9 +61,7 @@ fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Resu #[cfg(unix)] { let dav_url = format!("dav://127.0.0.1:{}/", port); - let _ = Command::new("gio") - .args(["mount", &dav_url]) - .output(); + let _ = Command::new("gio").args(["mount", &dav_url]).output(); let _ = (drive_letter, session_token); Ok(()) } @@ -106,8 +104,7 @@ pub async fn mount_container( println!(); ui::step(1, 4, "📦", "Öffne Container & verifiziere Header..."); } - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; let meta = db .read_meta() @@ -116,12 +113,22 @@ pub async fn mount_container( let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { if !stealth { - ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); + ui::step( + 2, + 4, + "🔑", + "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...", + ); } match meta.authenticate(password) { Some(keys) => { if !stealth { - ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); + ui::step( + 3, + 4, + "🔓", + "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!", + ); } let vault_id = keys.slot_id(); let ver = keys.version(); @@ -138,8 +145,7 @@ pub async fn mount_container( if !stealth { ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel..."); } - let dek = mnemonic_to_dek(phrase) - .context("Ungültiger 24-Wort Notfallschlüssel")?; + let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; if !stealth { ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!"); } @@ -152,10 +158,12 @@ pub async fn mount_container( println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1)."); println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt."); } - let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") - .context("Fehler beim Einlesen des Standard-Vault Passworts")?; - let decoy_keys = meta.authenticate(&decoy_pass) - .ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?; + let decoy_pass = + rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") + .context("Fehler beim Einlesen des Standard-Vault Passworts")?; + let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| { + anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.") + })?; (Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1) } else { (None, Some(carrier_node_id), 0) @@ -164,7 +172,13 @@ pub async fn mount_container( (None, None, 0) }; - (dek, carrier_dek, final_carrier_node_id, meta.version, vault_id) + ( + dek, + carrier_dek, + final_carrier_node_id, + meta.version, + vault_id, + ) } }; @@ -340,7 +354,10 @@ pub async fn mount_container( println!(); println!(" • Container: {}", container_path.display()); #[cfg(windows)] - println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); + println!( + " • Netzlaufwerk: {} (im Windows Explorer bereit)", + ui::cyan(&drive_str) + ); #[cfg(not(windows))] { if let Some(mp) = mount_point { @@ -349,12 +366,22 @@ 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 { @@ -376,9 +403,15 @@ pub async fn mount_container( } println!(); #[cfg(windows)] - println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp")); + println!( + " [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", + ui::yellow("Tipp") + ); #[cfg(not(windows))] - println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp")); + println!( + " [{}] Drücke [Ctrl+C] zum sicheren Beenden.", + ui::yellow("Tipp") + ); println!(); } @@ -386,11 +419,13 @@ pub async fn mount_container( #[cfg(windows)] let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1); #[cfg(windows)] - let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok(); + let _console_guard = + crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok(); // Unix Signale (SIGTERM, SIGHUP) #[cfg(unix)] - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok(); + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok(); #[cfg(unix)] let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok(); @@ -503,7 +538,11 @@ pub async fn mount_container( } else { #[cfg(windows)] { - print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); + print!( + " {} Trenne Windows-Netzlaufwerk {} ... ", + ui::dim("[-]"), + drive_str + ); let _ = std::io::Write::flush(&mut std::io::stdout()); // Automatisches Unmount @@ -527,7 +566,11 @@ pub async fn mount_container( // Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren if let Ok(freelist) = db.freelist_count() { if freelist > 0 { - print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist); + print!( + " {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", + ui::dim("[-]"), + freelist + ); let _ = std::io::Write::flush(&mut std::io::stdout()); match db.incremental_vacuum(None) { Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed), @@ -546,7 +589,10 @@ pub async fn mount_container( } println!(); - println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔")); + println!( + "{} Sanctum Container wurde sicher und vollständig geschlossen.", + ui::green("✔") + ); println!(); } @@ -616,6 +662,7 @@ const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_ /// Hilfsfunktion zur Validierung von HTTP Basic Auth. /// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht). +/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe. pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool { let auth_str = auth_header.trim(); let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") { @@ -637,44 +684,36 @@ pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool { Err(_) => return false, }; + use subtle::ConstantTimeEq; if let Some((_user, pass)) = decoded_str.split_once(':') { - pass == expected_token + pass.as_bytes().ct_eq(expected_token.as_bytes()).into() } else { false } } /// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers. +/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe. pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool { + use subtle::ConstantTimeEq; if let Some(val) = headers.get("X-Sanctum-Token") { if let Ok(val_str) = val.to_str() { - return val_str.trim() == expected_token; + return val_str + .trim() + .as_bytes() + .ct_eq(expected_token.as_bytes()) + .into(); } } 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; +/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598). +pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool { + if session_token.is_empty() { + return false; } - 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() + uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token) } /// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header. @@ -687,15 +726,15 @@ fn unauthorized_response() -> hyper::Response { .unwrap() } -/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus. +/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus. pub async fn serve_webdav_loop( listener: TcpListener, dav_server: DavHandler, 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); + let conn_semaphore = + std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS)); loop { tokio::select! { @@ -724,15 +763,13 @@ pub async fn serve_webdav_loop( let io = TokioIo::new(stream); let handler = dav_server.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 |mut req| { + let service = service_fn(move |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) let host_valid = match req.headers().get(hyper::header::HOST) { @@ -750,15 +787,27 @@ pub async fn serve_webdav_loop( ); let res = hyper::Response::builder() .status(hyper::StatusCode::FORBIDDEN) + .header(hyper::header::CONTENT_LENGTH, "0") .body(dav_server::body::Body::empty()) .unwrap(); return Ok::<_, Infallible>(res); } - // 2. R-05: Multi-Auth Middleware + // 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598). + // Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden. + if uri_contains_token(req.uri(), &token) { + warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)"); + let res = hyper::Response::builder() + .status(hyper::StatusCode::FORBIDDEN) + .header(hyper::header::CONTENT_LENGTH, "0") + .body(dav_server::body::Body::empty()) + .unwrap(); + return Ok::<_, Infallible>(res); + } + + // 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich // a) HTTP Basic Auth (Authorization: Basic ...) // b) Header X-Sanctum-Token - // c) Pfad-Präfix Fallback (//...) let mut authenticated = false; if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) { @@ -773,15 +822,6 @@ pub async fn serve_webdav_loop( 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()); @@ -841,15 +881,24 @@ mod tests { use base64::Engine; let token = "deadbeefcafebabe0123456789abcdef"; let creds = format!("sanctum:{}", token); - let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds)); + 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))); + 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")); + 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 @@ -873,22 +922,27 @@ mod tests { } #[test] - fn test_strip_path_prefix() { + fn test_uri_contains_token_rejection() { 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")); + // Token im Pfad + let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token) + .parse() + .unwrap(); + assert!(uri_contains_token(&uri_path, token)); - 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(), "/"); + // Token im Query-String + let uri_query: hyper::Uri = + format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token) + .parse() + .unwrap(); + assert!(uri_contains_token(&uri_query, token)); - 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()); + // Harmloser Pfad ohne Token + let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap(); + assert!(!uri_contains_token(&uri_clean, token)); + + // Leeres Token darf niemals matchen + assert!(!uri_contains_token(&uri_clean, "")); } } - - diff --git a/src/pathutil.rs b/src/pathutil.rs index bdb8e38..7edaab3 100644 --- a/src/pathutil.rs +++ b/src/pathutil.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +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). @@ -7,7 +7,10 @@ pub fn validate_node_name(name: &str) -> Result<()> { bail!("Dateiname darf nicht leer sein."); } if name == "." || name == ".." { - bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name); + bail!( + "Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", + name + ); } if name.contains('/') || name.contains('\\') || name.contains('\0') { bail!( @@ -29,9 +32,8 @@ pub fn validate_node_name(name: &str) -> Result<()> { }; 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", + "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!( diff --git a/src/recovery.rs b/src/recovery.rs index 7fd3965..352c118 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -85,11 +85,31 @@ impl HeaderBackup { let mut slots = Vec::new(); if !self.slots.is_empty() { + if self.slots.len() > 2 { + bail!( + "Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)", + self.slots.len() + ); + } + let mut seen_ids = std::collections::HashSet::new(); for s in &self.slots { - let salt_bytes = hex::decode(&s.kdf_salt_hex) - .context("Ungültige Hex-Kodierung für KDF-Salt")?; + if s.slot_id > 1 { + bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id); + } + if !seen_ids.insert(s.slot_id) { + bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id); + } + crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| { + anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id) + })?; + + let salt_bytes = + hex::decode(&s.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?; if salt_bytes.len() != 16 { - bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len()); + bail!( + "Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", + salt_bytes.len() + ); } let mut kdf_salt = [0u8; 16]; kdf_salt.copy_from_slice(&salt_bytes); @@ -97,10 +117,24 @@ impl HeaderBackup { let wrapped_dek = hex::decode(&s.wrapped_dek_hex) .context("Ungültige Hex-Kodierung für wrapped_dek")?; + if s.slot_id == 0 { + if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 { + bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()); + } + } else if s.slot_id == 1 { + if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 + { + bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()); + } + } + let nonce_bytes = hex::decode(&s.header_nonce_hex) .context("Ungültige Hex-Kodierung für Header-Nonce")?; if nonce_bytes.len() != 12 { - bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len()); + bail!( + "Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", + nonce_bytes.len() + ); } let mut header_nonce = [0u8; 12]; header_nonce.copy_from_slice(&nonce_bytes); @@ -108,7 +142,10 @@ impl HeaderBackup { let tag_bytes = hex::decode(&s.header_tag_hex) .context("Ungültige Hex-Kodierung für Header-Tag")?; if tag_bytes.len() != 16 { - bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len()); + bail!( + "Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", + tag_bytes.len() + ); } let mut header_tag = [0u8; 16]; header_tag.copy_from_slice(&tag_bytes); @@ -123,12 +160,19 @@ impl HeaderBackup { header_tag, }); } + + if !seen_ids.contains(&0) { + bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt"); + } } else { // Fallback für alte Backups ohne slots-Array - let salt_bytes = hex::decode(&self.kdf_salt_hex) - .context("Ungültige Hex-Kodierung für KDF-Salt")?; + let salt_bytes = + hex::decode(&self.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?; if salt_bytes.len() != 16 { - bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len()); + bail!( + "Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", + salt_bytes.len() + ); } let mut kdf_salt = [0u8; 16]; kdf_salt.copy_from_slice(&salt_bytes); @@ -139,7 +183,10 @@ impl HeaderBackup { let nonce_bytes = hex::decode(&self.header_nonce_hex) .context("Ungültige Hex-Kodierung für Header-Nonce")?; if nonce_bytes.len() != 12 { - bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len()); + bail!( + "Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", + nonce_bytes.len() + ); } let mut header_nonce = [0u8; 12]; header_nonce.copy_from_slice(&nonce_bytes); @@ -147,7 +194,10 @@ impl HeaderBackup { let tag_bytes = hex::decode(&self.header_tag_hex) .context("Ungültige Hex-Kodierung für Header-Tag")?; if tag_bytes.len() != 16 { - bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len()); + bail!( + "Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", + tag_bytes.len() + ); } let mut header_tag = [0u8; 16]; header_tag.copy_from_slice(&tag_bytes); @@ -180,19 +230,28 @@ impl HeaderBackup { /// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr). pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } let db = Database::open(container_path) .context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?; - let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + let meta = db + .read_meta() + .context("Konnte Container-Header nicht lesen")?; let backup = HeaderBackup::from_meta(&meta); let json_data = serde_json::to_string_pretty(&backup) .context("Fehler beim Serialisieren des Header-Backups")?; - fs::write(backup_path, json_data) - .with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?; + fs::write(backup_path, json_data).with_context(|| { + format!( + "Konnte Backup-Datei '{}' nicht schreiben", + backup_path.display() + ) + })?; Ok(()) } @@ -203,21 +262,25 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul bail!("Backup-Datei '{}' existiert nicht.", backup_path.display()); } - let content = fs::read_to_string(backup_path) - .with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?; + let content = fs::read_to_string(backup_path).with_context(|| { + format!( + "Konnte Backup-Datei '{}' nicht lesen", + backup_path.display() + ) + })?; let backup: HeaderBackup = serde_json::from_str(&content) .context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?; let meta = backup.to_meta()?; - let db = Database::open(container_path) - .context("Konnte Ziel-Containerdatei nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?; db.restore_meta(&meta) .context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?; - db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?; + db.checkpoint() + .context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?; Ok(()) } @@ -256,14 +319,16 @@ pub fn restore_slot_from_recovery_key( dek_0: Option<&[u8; 32]>, ) -> Result<()> { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } validate_password(new_password)?; // 1. DEK aus 24-Wort-Phrase dekodieren & validieren - let dek = mnemonic_to_dek(recovery_key) - .context("Ungültiger 24-Wort Notfallschlüssel")?; + let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?; // 2. Neuen KEK mit frischem Salt ableiten let mut salt = [0u8; 16]; @@ -273,8 +338,7 @@ pub fn restore_slot_from_recovery_key( let kek = derive_kek(new_password, &salt, &kdf_params) .context("Schlüsselableitung für neues Passwort fehlgeschlagen")?; - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0); @@ -347,7 +411,8 @@ pub fn restore_slot_from_recovery_key( db.restore_meta(&meta) .context("Fehler beim Schreiben des rekonstruierten Headers")?; - db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?; + db.checkpoint() + .context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?; Ok(()) } @@ -370,8 +435,10 @@ mod tests { #[test] fn test_header_backup_and_restore() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id())); - let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id())); + let container_path: PathBuf = + temp_dir.join(format!("test_backup_{}.sanctum", std::process::id())); + let backup_path: PathBuf = + temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id())); if container_path.exists() { let _ = fs::remove_file(&container_path); @@ -392,7 +459,8 @@ mod tests { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); db.checkpoint().unwrap(); // 1. Export @@ -416,8 +484,13 @@ mod tests { let restored_db = Database::open(&container_path).unwrap(); let meta = restored_db.read_meta().expect("Read restored meta"); let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap(); - let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) - .expect("Unwrap restored DEK"); + let active_dek = unwrap_dek( + &restored_kek, + &meta.wrapped_dek, + &meta.header_nonce, + &meta.header_tag, + ) + .expect("Unwrap restored DEK"); assert_eq!(*dek, *active_dek); let _ = fs::remove_file(&container_path); @@ -427,7 +500,8 @@ mod tests { #[test] fn test_restore_from_recovery_key() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id())); + let container_path: PathBuf = + temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id())); if container_path.exists() { let _ = fs::remove_file(&container_path); @@ -450,7 +524,8 @@ mod tests { let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); db.checkpoint().unwrap(); // Header zerstören @@ -466,8 +541,13 @@ mod tests { let rescued_db = Database::open(&container_path).unwrap(); let meta = rescued_db.read_meta().expect("Read rescued meta"); let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap(); - let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) - .expect("Unwrap rescued DEK"); + let unwrapped = unwrap_dek( + &new_kek, + &meta.wrapped_dek, + &meta.header_nonce, + &meta.header_tag, + ) + .expect("Unwrap rescued DEK"); assert_eq!(*dek, *unwrapped); let _ = fs::remove_file(&container_path); diff --git a/src/storage.rs b/src/storage.rs index 3fcdd5a..7c80dd3 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -9,8 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::crypto::{ decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload, - validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, - MAGIC_BYTES, + validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, + FORMAT_VERSION_V2, MAGIC_BYTES, }; use zeroize::Zeroizing; @@ -52,11 +52,11 @@ pub struct SlotMeta { /// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`). #[derive(Clone)] pub struct UnlockedKeys( - pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1) - pub u32, // 1: Formatversion - pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault) + pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1) + pub u32, // 1: Formatversion + pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault) pub Option>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden) - pub Option, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes) + pub Option, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes) ); impl UnlockedKeys { @@ -94,12 +94,39 @@ impl ContainerMeta { /// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch. /// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id). pub fn authenticate(&self, password: &str) -> Option { + // SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek) + // Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten + if self.slots.is_empty() || self.slots.len() > 2 { + return None; + } + if !self.slots.iter().any(|s| s.slot_id == 0) { + return None; + } + if self.slots.iter().any(|s| s.slot_id > 1) { + return None; + } + let mut seen_ids = std::collections::HashSet::new(); + for slot in &self.slots { + if !seen_ids.insert(slot.slot_id) { + return None; + } + if validate_kdf_params(&slot.kdf_params).is_err() { + return None; + } + } + let mut matching = None; for slot in &self.slots { let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) .ok() .and_then(|kek| { - unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok() + unwrap_key_payload( + &kek, + &slot.wrapped_dek, + &slot.header_nonce, + &slot.header_tag, + ) + .ok() }); if let Some(payload) = res { @@ -109,7 +136,11 @@ impl ContainerMeta { let carrier_node_id = if payload.len() >= 40 { dek.copy_from_slice(&payload[0..32]); let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap()); - if cid > 0 { Some(cid) } else { None } + if cid > 0 { + Some(cid) + } else { + None + } } else if payload.len() >= 32 { dek.copy_from_slice(&payload[0..32]); None @@ -135,7 +166,13 @@ impl ContainerMeta { } else { continue; }; - matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id)); + matching = Some(UnlockedKeys( + dek_1, + slot.version, + 1, + carrier_dek, + carrier_node_id, + )); } } } @@ -242,6 +279,27 @@ impl Database { path_ref.display() ); } + + // SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS) + let has_meta_table: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if has_meta_table { + let slot_count: i64 = conn + .query_row("SELECT count(*) FROM meta;", [], |r| r.get(0)) + .unwrap_or(0); + if slot_count > 2 { + bail!( + "Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)", + slot_count + ); + } + } } let db = Self { @@ -285,17 +343,25 @@ impl Database { /// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch. pub fn ensure_schema_upgrades(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); - let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0); + let av: i64 = conn + .query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)) + .unwrap_or(0); if av != 2 { // Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;"); } // Spalte slot_id in meta (falls aus v1 migriert) - let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []); + let _ = conn.execute( + "ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", + [], + ); // Spalte is_carrier in nodes (S-03 Carrier-Schutz) - let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []); + let _ = conn.execute( + "ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", + [], + ); Ok(()) } @@ -318,7 +384,9 @@ impl Database { /// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben. pub fn incremental_vacuum(&self, pages: Option) -> Result { let conn = self.conn.lock().unwrap(); - let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0); + let before: i64 = conn + .query_row("PRAGMA freelist_count;", [], |r| r.get(0)) + .unwrap_or(0); if before <= 0 { return Ok(0); } @@ -337,7 +405,9 @@ impl Database { drop(rows); drop(stmt); - let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0); + let after: i64 = conn + .query_row("PRAGMA freelist_count;", [], |r| r.get(0)) + .unwrap_or(0); let actual_freed = (before - after).max(0) as usize; Ok(actual_freed.max(stepped)) } @@ -352,9 +422,7 @@ impl Database { /// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03). pub fn find_carrier_node_id(&self) -> Result> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1", - )?; + let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?; let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?; Ok(id) } @@ -380,16 +448,26 @@ impl Database { ) SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;", )?; - let exists: Option = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?; + let exists: Option = stmt + .query_row(params![ancestor_id, node_id], |r| r.get(0)) + .optional()?; Ok(exists.is_some()) } /// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding). + /// + /// SA-07 / Technischer Hinweis zu sicherem Löschen: + /// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records + /// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor + /// logischer Wiederherstellung auf Dateisystem- und Datenbankebene. + /// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) + /// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) + /// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren + /// neuen Flash-Speicherzellen zuweisen. pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1" - )?; + let mut stmt = + conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; let chunks: Vec<(u32, usize)> = stmt .query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))? .filter_map(|r| r.ok()) @@ -426,15 +504,15 @@ impl Database { header_nonce_0: &[u8; 12], header_tag_0: &[u8; 16], carrier_config: Option<( - &str, // carrier_name - u64, // carrier_size_bytes - &[u8; 16], // salt_1 - &KdfParams, // kdf_params_1 - &[u8], // wrapped_dek_1 (72B) - &[u8; 12], // header_nonce_1 - &[u8; 16], // header_tag_1 - &[u8; 32], // raw DEK_0 - &[u8; 32], // raw DEK_1 + &str, // carrier_name + u64, // carrier_size_bytes + &[u8; 16], // salt_1 + &KdfParams, // kdf_params_1 + &[u8], // wrapped_dek_1 (72B) + &[u8; 12], // header_nonce_1 + &[u8; 16], // header_tag_1 + &[u8; 32], // raw DEK_0 + &[u8; 32], // raw DEK_1 )>, ) -> Result> { let conn = self.conn.lock().unwrap(); @@ -547,13 +625,8 @@ impl Database { let manifest = crate::carrier::CarrierManifest::new(total_blocks); let manifest_bytes = serde_json::to_vec(&manifest)?; - let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk( - dek_1, - c_id, - 0, - &manifest_bytes, - FORMAT_VERSION, - )?; + let (inner_ct, inner_nonce, inner_tag) = + crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?; let inner_ct_len = inner_ct.len() as u32; let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; @@ -568,13 +641,8 @@ impl Database { } outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); - let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk( - dek_0, - c_id, - 0, - &outer_plaintext, - FORMAT_VERSION, - )?; + let (outer_ct, outer_nonce, outer_tag) = + crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) @@ -593,20 +661,9 @@ impl Database { conn.execute_batch("BEGIN TRANSACTION;")?; for b in 1..total_blocks { - let (ct, nonce, tag) = crate::crypto::encrypt_chunk( - dek_0, - c_id, - b, - &dummy_noise, - FORMAT_VERSION, - )?; - chunk_stmt.execute(params![ - c_id, - b, - nonce.as_slice(), - tag.as_slice(), - ct, - ])?; + let (ct, nonce, tag) = + crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?; + chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?; if b % 500 == 0 { conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?; @@ -767,18 +824,43 @@ impl Database { header_nonce: &[u8; 12], header_tag: &[u8; 16], ) -> Result<()> { - self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None) + self.init_schema_with_hidden( + salt, + kdf_params, + wrapped_dek, + header_nonce, + header_tag, + None, + ) } /// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen). + /// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch. pub fn read_slots(&self) -> Result> { let conn = self.conn.lock().unwrap(); + + // SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS) + let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?; + if slot_count == 0 { + bail!("Container-Header ist leer oder beschädigt"); + } + if slot_count > 2 { + bail!( + "Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)", + slot_count + ); + } + let mut stmt = conn.prepare( "SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag FROM meta ORDER BY slot_id ASC", )?; - let rows = stmt.query_map([], |row| { + let mut rows = stmt.query([])?; + let mut slots = Vec::new(); + let mut seen_slot_ids = std::collections::HashSet::new(); + + while let Some(row) = rows.next()? { let slot_id: u32 = row.get(0)?; let version: u32 = row.get(1)?; let salt_vec: Vec = row.get(2)?; @@ -787,91 +869,69 @@ impl Database { let nonce_vec: Vec = row.get(5)?; let tag_vec: Vec = row.get(6)?; - let mut kdf_salt = [0u8; 16]; - if salt_vec.len() != 16 { - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()), - )), - )); + // SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig + if slot_id > 1 { + bail!( + "Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", + slot_id + ); } + if !seen_slot_ids.insert(slot_id) { + bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id); + } + + if salt_vec.len() != 16 { + bail!( + "Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", + slot_id, + salt_vec.len() + ); + } + let mut kdf_salt = [0u8; 16]; kdf_salt.copy_from_slice(&salt_vec); - let mut header_nonce = [0u8; 12]; if nonce_vec.len() != 12 { - return Err(rusqlite::Error::FromSqlConversionFailure( - 5, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()), - )), - )); + bail!( + "Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", + slot_id, + nonce_vec.len() + ); } + let mut header_nonce = [0u8; 12]; header_nonce.copy_from_slice(&nonce_vec); - let mut header_tag = [0u8; 16]; if tag_vec.len() != 16 { - return Err(rusqlite::Error::FromSqlConversionFailure( - 6, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()), - )), - )); + bail!( + "Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", + slot_id, + tag_vec.len() + ); } + let mut header_tag = [0u8; 16]; header_tag.copy_from_slice(&tag_vec); // Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz) if slot_id == 0 { if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 { - return Err(rusqlite::Error::FromSqlConversionFailure( - 4, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()), - )), - )); + bail!( + "Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", + wrapped_dek.len() + ); } } else if slot_id == 1 { if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 { - return Err(rusqlite::Error::FromSqlConversionFailure( - 4, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()), - )), - )); + bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()); } } - let kdf_params: KdfParams = serde_json::from_str(¶ms_str) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id), - )), - ))?; + let kdf_params: KdfParams = serde_json::from_str(¶ms_str).map_err(|e| { + anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id) + })?; validate_kdf_params(&kdf_params) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id), - )), - ))?; + .map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?; - Ok(SlotMeta { + slots.push(SlotMeta { slot_id, version, kdf_salt, @@ -879,13 +939,14 @@ impl Database { wrapped_dek, header_nonce, header_tag, - }) - })?; - - let mut slots = Vec::new(); - for r in rows { - slots.push(r?); + }); } + + // SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich + if !seen_slot_ids.contains(&0) { + bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt"); + } + Ok(slots) } @@ -909,7 +970,10 @@ impl Database { } if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 { - bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version); + bail!( + "Nicht unterstützte Sanctum-Formatversion: {}", + slot0.version + ); } Ok(ContainerMeta { @@ -932,7 +996,14 @@ impl Database { new_header_nonce: &[u8; 12], new_header_tag: &[u8; 16], ) -> Result<()> { - self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag) + self.update_slot_keys( + 0, + new_salt, + new_params, + new_wrapped_dek, + new_header_nonce, + new_header_tag, + ) } /// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots. @@ -960,7 +1031,10 @@ impl Database { )?; if rows_affected == 0 { - bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id); + bail!( + "Konnte Container-Header für Slot {} nicht aktualisieren", + slot_id + ); } Ok(()) @@ -1059,7 +1133,8 @@ impl Database { let mut matched_record = None; for r in rows { let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; - let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name); + let dec_name = + decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name); if dec_name == *segment { matched_record = Some(NodeRecord { id, @@ -1471,11 +1546,10 @@ impl Database { /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. pub fn checkpoint(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); - let _res: (i64, i64, i64) = conn.query_row( - "PRAGMA wal_checkpoint(TRUNCATE);", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let _res: (i64, i64, i64) = + conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; Ok(()) } @@ -1650,10 +1724,9 @@ impl Database { /// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container. pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> { let conn = self.conn.lock().unwrap(); - let dirs: i64 = - conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| { - r.get(0) - })?; + let dirs: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| { + r.get(0) + })?; let files: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| { r.get(0) @@ -1717,7 +1790,8 @@ mod tests { let nonce = [3u8; 12]; let tag = [4u8; 16]; - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); // Meta abrufen let meta = db.read_meta().unwrap(); @@ -1740,19 +1814,29 @@ mod tests { assert!(!file.is_dir); // Pfadauflösung testen - let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve"); + let resolved_file = db + .resolve_path("/documents/notes.txt") + .unwrap() + .expect("File should resolve"); assert_eq!(resolved_file.id, file.id); - let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve"); + let resolved_docs = db + .resolve_path("documents") + .unwrap() + .expect("Docs should resolve"); assert_eq!(resolved_docs.id, docs.id); // Chunks schreiben & lesen let test_cipher = b"ENCRYPTED_DATA_BLOCK"; let c_nonce = [7u8; 12]; let c_tag = [8u8; 16]; - db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap(); + db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher) + .unwrap(); - let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist"); + let chunk = db + .read_chunk(file.id, 0) + .unwrap() + .expect("Chunk 0 should exist"); assert_eq!(chunk.ciphertext, test_cipher); // Truncate @@ -1782,7 +1866,8 @@ mod tests { let nonce = [3u8; 12]; let tag = [4u8; 16]; - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); let root = db.resolve_path("/").unwrap().expect("Root node"); let file = db.create_node(root.id, "large_file.bin", false).unwrap(); @@ -1792,7 +1877,8 @@ mod tests { let c_nonce = [5u8; 12]; let c_tag = [6u8; 16]; for i in 0..20 { - db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap(); + db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload) + .unwrap(); } db.checkpoint().unwrap(); @@ -1801,12 +1887,18 @@ mod tests { db.checkpoint().unwrap(); let freelist_before = db.freelist_count().unwrap(); - assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten"); + assert!( + freelist_before > 0, + "Freelist sollte nach dem Löschen freie Seiten enthalten" + ); // Incremental Vacuum ausführen let freed = db.incremental_vacuum(None).unwrap(); assert!(freed > 0, "Es sollten Seiten freigegeben werden"); - assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden"); + assert_eq!( + freed, freelist_before, + "Alle freien Seiten müssen freigegeben werden" + ); let freelist_after = db.freelist_count().unwrap(); assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein"); @@ -1823,22 +1915,34 @@ mod tests { let nonce = [3u8; 12]; let tag = [4u8; 16]; - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); let root = db.resolve_path("/").unwrap().expect("Root"); let file = db.create_node(root.id, "sensitive.dat", false).unwrap(); let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT"; let c_nonce = [10u8; 12]; let c_tag = [11u8; 16]; - db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap(); + db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload) + .unwrap(); // Shredde Chunks db.shred_chunks_for_node(file.id).unwrap(); // Prüfe, was sich in der Chunks-Tabelle befindet - let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch"); - assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!"); - assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein"); + let chunk = db + .read_chunk(file.id, 0) + .unwrap() + .expect("Chunk existiert noch"); + assert_ne!( + chunk.ciphertext, sensitive_payload, + "Ciphertext muss überschrieben sein!" + ); + assert_eq!( + chunk.ciphertext.len(), + sensitive_payload.len(), + "Länge muss identisch sein" + ); assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein"); assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein"); } @@ -1862,9 +1966,14 @@ mod tests { let dek1 = [0xBBu8; 32]; db.init_schema_with_hidden( - &salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0, + &salt0, + &kdf_params0, + &wrapped_dek0, + &nonce0, + &tag0, Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)), - ).unwrap(); + ) + .unwrap(); // Slots prüfen let slots = db.read_slots().unwrap(); @@ -1873,63 +1982,107 @@ mod tests { assert_eq!(slots[1].slot_id, 1); // Datei in Vault 0 (Decoy) erstellen - let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0"); + let root0 = db + .resolve_path_in_vault("/", 0, &dek0) + .unwrap() + .expect("Root 0"); assert_eq!(root0.id, 1); - let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap(); + let decoy_file = db + .create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0) + .unwrap(); // Datei in Vault 1 (Hidden) erstellen - let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1"); + let root1 = db + .resolve_path_in_vault("/", 1, &dek1) + .unwrap() + .expect("Root 1"); assert_eq!(root1.id, 2); - let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap(); + let hidden_file = db + .create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1) + .unwrap(); // Auflösen in Vault 0: Sieht nur public_recipe.txt - let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap(); + let res_decoy = db + .resolve_path_in_vault("/public_recipe.txt", 0, &dek0) + .unwrap(); assert!(res_decoy.is_some()); assert_eq!(res_decoy.unwrap().id, decoy_file.id); - let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap(); - assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!"); + let res_hidden_in_v0 = db + .resolve_path_in_vault("/classified_leak.pdf", 0, &dek0) + .unwrap(); + assert!( + res_hidden_in_v0.is_none(), + "Vault 0 darf keine Dateien aus Hidden Vault auflösen!" + ); // Auflösen in Vault 1: Sieht nur classified_leak.pdf - let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap(); + let res_hidden = db + .resolve_path_in_vault("/classified_leak.pdf", 1, &dek1) + .unwrap(); assert!(res_hidden.is_some()); assert_eq!(res_hidden.unwrap().id, hidden_file.id); - let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap(); - assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!"); + let res_decoy_in_v1 = db + .resolve_path_in_vault("/public_recipe.txt", 1, &dek1) + .unwrap(); + assert!( + res_decoy_in_v1.is_none(), + "Vault 1 darf keine Dateien aus Vault 0 auflösen!" + ); // Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen let conn = db.conn.lock().unwrap(); - let raw_name_v0: String = conn.query_row( - "SELECT name FROM nodes WHERE id = ?1", - params![decoy_file.id], - |r| r.get(0), - ).unwrap(); + let raw_name_v0: String = conn + .query_row( + "SELECT name FROM nodes WHERE id = ?1", + params![decoy_file.id], + |r| r.get(0), + ) + .unwrap(); assert_eq!(raw_name_v0, "public_recipe.txt"); - let raw_name_v1: String = conn.query_row( - "SELECT name FROM nodes WHERE id = ?1", - params![hidden_file.id], - |r| r.get(0), - ).unwrap(); + let raw_name_v1: String = conn + .query_row( + "SELECT name FROM nodes WHERE id = ?1", + params![hidden_file.id], + |r| r.get(0), + ) + .unwrap(); // Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext - assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen"); - assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen"); + assert!( + !raw_name_v1.starts_with("$h$"), + "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen" + ); + assert!( + !raw_name_v1.contains("classified_leak"), + "Plaintext darf keinesfalls in SQLite DB auftauchen" + ); // Keine Spalte `vault_id` in nodes oder chunks - let has_vault_id_nodes: i64 = conn.query_row( - "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", - [], - |r| r.get(0), - ).unwrap(); - assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren"); + let has_vault_id_nodes: i64 = conn + .query_row( + "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + has_vault_id_nodes, 0, + "vault_id darf nicht in nodes existieren" + ); - let has_vault_id_chunks: i64 = conn.query_row( - "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", - [], - |r| r.get(0), - ).unwrap(); - assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren"); + let has_vault_id_chunks: i64 = conn + .query_row( + "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + has_vault_id_chunks, 0, + "vault_id darf nicht in chunks existieren" + ); } #[test] @@ -1942,7 +2095,8 @@ mod tests { [3u8; 12], [4u8; 16], ); - db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) + .unwrap(); let file = db.create_node(1, "crash_test.bin", false).unwrap(); assert_eq!(file.size, 0); @@ -1961,10 +2115,14 @@ mod tests { &ciphertext, new_size, modified_at, - ).expect("Atomic write"); + ) + .expect("Atomic write"); // Chunk verifizieren - let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist"); + let chunk = db + .read_chunk(file.id, 0) + .unwrap() + .expect("Chunk must exist"); assert_eq!(chunk.ciphertext, ciphertext); assert_eq!(chunk.nonce, chunk_nonce); assert_eq!(chunk.tag, chunk_tag); diff --git a/src/sync.rs b/src/sync.rs index 618ebb9..1be8134 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -141,7 +141,8 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool { // Wildcard-Muster: prefix* else if p.ends_with('*') && !p[..p.len() - 1].contains('*') { let prefix = p[..p.len() - 1].to_ascii_lowercase(); - if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) { + if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) + { return true; } } @@ -151,7 +152,9 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool { if norm_name == p_lower { return true; } - if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') { + if norm_rel.trim_start_matches('/').to_ascii_lowercase() + == p_lower.trim_start_matches('/') + { return true; } } @@ -176,8 +179,8 @@ fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result /// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes. fn set_local_file_mtime(file: &File, mtime_secs: u64) { - let times = std::fs::FileTimes::new() - .set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs)); + let times = + std::fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs)); let _ = file.set_times(times); } @@ -206,7 +209,10 @@ pub fn ensure_vault_dir_tree( let children = db.list_children_in_vault(current_id, vault_id, dek)?; if let Some(existing) = children.into_iter().find(|c| c.name == segment) { if !existing.is_dir { - bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment); + bail!( + "Pfad-Konflikt: '{}' existiert im Container bereits als Datei", + segment + ); } current_id = existing.id; current_node = existing; @@ -234,8 +240,12 @@ pub fn sync_single_file_to_vault( ) -> Result { validate_node_name(file_name)?; - let meta = fs::metadata(local_path) - .with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?; + let meta = fs::metadata(local_path).with_context(|| { + format!( + "Konnte Metadaten für '{}' nicht lesen", + local_path.display() + ) + })?; let local_size = meta.len(); let local_mtime = meta .modified() @@ -250,7 +260,10 @@ pub fn sync_single_file_to_vault( 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); + bail!( + "Pfad-Konflikt: '{}' existiert im Tresor als Ordner", + file_name + ); } // Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum) @@ -278,7 +291,8 @@ pub fn sync_single_file_to_vault( let node_id = match existing_node { Some(n) => n.id, None => { - let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?; + let new_node = + db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?; new_node.id } }; @@ -370,8 +384,12 @@ pub fn sync_single_file_to_host( fs::create_dir_all(parent)?; } - let mut out_file = File::create(local_path) - .with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?; + let mut out_file = File::create(local_path).with_context(|| { + format!( + "Konnte Zieldatei '{}' nicht erstellen", + local_path.display() + ) + })?; let total_chunks = if node.size == 0 { 0 @@ -381,10 +399,22 @@ pub fn sync_single_file_to_host( for idx in 0..total_chunks { if let Some(record) = db.read_chunk(node.id, idx)? { - let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?; + let plaintext = decrypt_chunk( + dek, + node.id, + idx, + &record.ciphertext, + &record.nonce, + &record.tag, + version, + )?; out_file.write_all(&plaintext)?; } else { - bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name); + bail!( + "Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", + idx, + node.name + ); } } @@ -409,10 +439,14 @@ pub fn run_sync( match options.direction { SyncDirection::Push => { - sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?; + sync_push( + db, vault_id, dek, version, source_arg, target_arg, options, &mut stats, + )?; } SyncDirection::Pull => { - sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?; + sync_pull( + db, vault_id, dek, version, source_arg, target_arg, options, &mut stats, + )?; } } @@ -436,7 +470,11 @@ fn sync_push( bail!("Lokale Quelle '{}' existiert nicht.", source_str); } - let target_vault_dir = if target_str.is_empty() { "/" } else { target_str }; + let target_vault_dir = if target_str.is_empty() { + "/" + } else { + target_str + }; if local_source.is_file() { let file_name = local_source @@ -469,14 +507,24 @@ fn sync_push( stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size)); + println!( + " {} Übertragen: {} ({})", + ui::green("[+]"), + file_name, + ui::format_bytes(size) + ); } } FileTransferResult::DryRunTransferred { size } => { stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size)); + println!( + " {} [DRY-RUN] Würde übertragen: {} ({})", + ui::yellow("[~]"), + file_name, + ui::format_bytes(size) + ); } } FileTransferResult::Skipped { .. } => { @@ -556,11 +604,20 @@ fn collect_and_push_dir( if path.is_dir() { // Ordner im Tresor anlegen falls nötig let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?; - let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) { + let sub_dir_node = match children + .into_iter() + .find(|c| c.name == file_name && c.is_dir) + { Some(n) => n, None => { if !options.dry_run { - db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)? + db.create_node_in_vault( + vault_id, + current_vault_parent_id, + &file_name, + true, + dek, + )? } else { // Dummy für dry-run NodeRecord { @@ -605,14 +662,24 @@ fn collect_and_push_dir( stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size)); + println!( + " {} Übertragen: {} ({})", + ui::green("[+]"), + rel_path, + ui::format_bytes(size) + ); } } FileTransferResult::DryRunTransferred { size } => { stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size)); + println!( + " {} [DRY-RUN] Würde übertragen: {} ({})", + ui::yellow("[~]"), + rel_path, + ui::format_bytes(size) + ); } } FileTransferResult::Skipped { .. } => { @@ -644,7 +711,10 @@ pub fn delete_orphans_in_vault( for child in children { // S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen! - if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) { + if carrier_id > 0 + && (child.id == carrier_id + || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) + { continue; } @@ -658,7 +728,11 @@ pub fn delete_orphans_in_vault( stats.files_deleted += 1; if dry_run { if !quiet { - println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel); + println!( + " {} [DRY-RUN] Würde aus Tresor löschen: {}", + ui::red("[-]"), + child_rel + ); } } else { db.delete_node(child.id)?; @@ -724,20 +798,34 @@ fn sync_pull( stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size)); + println!( + " {} Wiederhergestellt: {} ({})", + ui::green("[+]"), + local_file_path.display(), + ui::format_bytes(size) + ); } } FileTransferResult::DryRunTransferred { size } => { stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size)); + println!( + " {} [DRY-RUN] Würde wiederherstellen: {} ({})", + ui::yellow("[~]"), + local_file_path.display(), + ui::format_bytes(size) + ); } } FileTransferResult::Skipped { .. } => { stats.files_skipped += 1; if !options.quiet { - println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display()); + println!( + " {} Aktuell (übersprungen): {}", + ui::dim("[=]"), + local_file_path.display() + ); } } } @@ -795,7 +883,10 @@ fn collect_and_pull_dir( 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)) { + if carrier_id > 0 + && (child.id == carrier_id + || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) + { continue; } @@ -816,7 +907,10 @@ fn collect_and_pull_dir( for comp in Path::new(&child_rel).components() { match comp { std::path::Component::Normal(_) => {} - _ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel), + _ => bail!( + "Path traversal Versuch erkannt in relativem Pfad: '{}'", + child_rel + ), } } @@ -824,7 +918,10 @@ fn collect_and_pull_dir( let local_child_path = local_target_base.join(&child_rel.replace('/', "\\")); if !local_child_path.starts_with(local_target_base) { - bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel); + bail!( + "Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", + child_rel + ); } if child.is_dir { @@ -859,14 +956,24 @@ fn collect_and_pull_dir( stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size)); + println!( + " {} Wiederhergestellt: {} ({})", + ui::green("[+]"), + child_rel, + ui::format_bytes(size) + ); } } FileTransferResult::DryRunTransferred { size } => { stats.files_transferred += 1; stats.bytes_transferred += size; if !options.quiet { - println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size)); + println!( + " {} [DRY-RUN] Würde wiederherstellen: {} ({})", + ui::yellow("[~]"), + child_rel, + ui::format_bytes(size) + ); } } FileTransferResult::Skipped { .. } => { @@ -908,7 +1015,11 @@ fn delete_orphans_on_host( if path.is_dir() { if dry_run { if !quiet { - println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path); + println!( + " {} [DRY-RUN] Würde lokalen Ordner löschen: {}", + ui::red("[-]"), + rel_path + ); } } else { fs::remove_dir_all(&path)?; @@ -919,7 +1030,11 @@ fn delete_orphans_on_host( } else { if dry_run { if !quiet { - println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path); + println!( + " {} [DRY-RUN] Würde lokale Datei löschen: {}", + ui::red("[-]"), + rel_path + ); } } else { fs::remove_file(&path)?; @@ -951,10 +1066,18 @@ mod tests { ]; assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns)); - assert!(is_excluded("download.crdownload", "download.crdownload", &patterns)); + assert!(is_excluded( + "download.crdownload", + "download.crdownload", + &patterns + )); assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns)); assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns)); - assert!(!is_excluded("important.doc", "sub/important.doc", &patterns)); + assert!(!is_excluded( + "important.doc", + "sub/important.doc", + &patterns + )); assert!(!is_excluded("video.mp4", "video.mp4", &patterns)); } } diff --git a/src/ui.rs b/src/ui.rs index c17dc96..9b29fd1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -131,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) { pub fn print_recovery_phrase_card(phrase: &str) { let words: Vec<&str> = phrase.split_whitespace().collect(); println!(); - println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐")); - println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")); - println!("{}", yellow("├─────────────────────────────────────────────────────────────┤")); + println!( + "{}", + yellow("┌─────────────────────────────────────────────────────────────┐") + ); + println!( + "{}", + yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │") + ); + println!( + "{}", + yellow("├─────────────────────────────────────────────────────────────┤") + ); println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │"); println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │"); println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │"); - println!("{}", yellow("├─────────────────────────────────────────────────────────────┤")); + println!( + "{}", + yellow("├─────────────────────────────────────────────────────────────┤") + ); for row in 0..8 { let w1 = if row < words.len() { @@ -158,7 +170,10 @@ pub fn print_recovery_phrase_card(phrase: &str) { println!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3)); } - println!("{}", yellow("└─────────────────────────────────────────────────────────────┘")); + println!( + "{}", + yellow("└─────────────────────────────────────────────────────────────┘") + ); println!(); } @@ -204,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) { println!(" - Daten-Chunks: {}", report.total_chunks); if report.total_bytes_decrypted > 0 { let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0); - println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb); + println!( + " - Verifiziert: {:.2} MB (vollständig entschlüsselt)", + mb + ); } if !report.errors.is_empty() { @@ -217,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) { println!(); if report.is_healthy() { - println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.")); + println!( + " {}", + green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.") + ); } else { - println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")); + println!( + " {}", + red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.") + ); } println!(); } - diff --git a/src/upgrade.rs b/src/upgrade.rs index 6453861..23e7626 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -65,11 +65,7 @@ impl Default for UpgradeOptions { } /// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02). -pub fn verify_minisign_signature( - data: &[u8], - sig_str: &str, - pubkey_str: &str, -) -> Result<()> { +pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<()> { let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str) .map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?; let signature = minisign_verify::Signature::decode(sig_str) @@ -138,7 +134,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result { let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base); let resp = ureq::get(&api_url) - .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .set( + "User-Agent", + &format!("sanctum/{}", env!("CARGO_PKG_VERSION")), + ) .set("Accept", "application/json") .timeout(Duration::from_secs(15)) .call() @@ -154,7 +153,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result { /// Lädt eine Datei von einer URL als Text herunter. pub fn fetch_text(url: &str) -> Result { let resp = ureq::get(url) - .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .set( + "User-Agent", + &format!("sanctum/{}", env!("CARGO_PKG_VERSION")), + ) .timeout(Duration::from_secs(15)) .call() .with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?; @@ -212,7 +214,10 @@ pub fn download_and_verify_binary( expected_size: u64, ) -> Result { let resp = ureq::get(download_url) - .set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION"))) + .set( + "User-Agent", + &format!("sanctum/{}", env!("CARGO_PKG_VERSION")), + ) .timeout(Duration::from_secs(120)) .call() .with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?; @@ -285,7 +290,10 @@ pub fn download_and_verify_binary( ); } - println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16])); + println!( + " • SHA-256: {} (verifiziert)", + ui::green(&calculated_hash[..16]) + ); let (_, path) = temp_file .keep() @@ -324,12 +332,16 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> { println!("┌─────────────────────────────────────────────────────────────┐"); println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │"); println!("└─────────────────────────────────────────────────────────────┘"); - println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version))); + println!( + " • Aktuelle Version: {}", + ui::cyan(&format!("v{}", current_version)) + ); println!(" • Server: {}", options.base_url); println!(); // S-02: Prüfung auf abweichende Server-URL - let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/'); + let is_official_server = + options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/'); if !is_official_server { if !options.insecure_url { bail!( @@ -389,12 +401,22 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> { } // 2. Neuestes Release abfragen - println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]")); + println!( + " {} Suche nach neuesten Releases auf Gitea ...", + ui::dim("[-]") + ); let release = fetch_latest_release(&options.base_url)?; let remote_tag = &release.tag_name; let is_newer = is_newer_version(current_version, remote_tag); - println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) }); + println!( + " • Neueste Version: {}", + if is_newer { + ui::green(remote_tag) + } else { + ui::cyan(remote_tag) + } + ); println!(" • Release-Name: {}", release.name); if let Some(ref pub_at) = release.published_at { println!(" • Veröffentlicht: {}", pub_at); @@ -459,27 +481,46 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> { })?; // 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02) - println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME); + println!( + " {} Lade Prüfsummen ({}) herunter ...", + ui::dim("[-]"), + CHECKSUM_ASSET_NAME + ); let sums_content = fetch_text(&checksum_asset.browser_download_url)?; - println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME); + println!( + " {} Lade Minisign-Signatur ({}) herunter ...", + ui::dim("[-]"), + CHECKSUM_SIG_ASSET_NAME + ); let sig_content = fetch_text(&sig_asset.browser_download_url)?; - println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]")); + println!( + " {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", + ui::dim("[-]") + ); verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY) .context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?; - println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig")); + println!( + " • Signatur: {} (Minisign Ed25519)", + ui::green("Gültig") + ); - let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| { - anyhow::anyhow!( - "Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!", - TARGET_ASSET_NAME - ) - })?; + let expected_hash = + parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| { + anyhow::anyhow!( + "Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!", + TARGET_ASSET_NAME + ) + })?; // 5. Interaktive Bestätigung (sofern nicht -y / --yes) if !options.yes { - println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size)); + println!( + " • Ziel-Binary: {} ({})", + target_asset.name, + ui::format_bytes(target_asset.size) + ); println!(" • Erwarteter Hash: {}...", &expected_hash[..16]); println!(); print!( @@ -513,7 +554,10 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> { println!(); println!("┌─────────────────────────────────────────────────────────────┐"); - println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag); + println!( + "│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", + remote_tag + ); println!("└─────────────────────────────────────────────────────────────┘"); println!(" • Pfad: {}", updated_exe.display()); println!(" • Neue Version: {}", ui::green(remote_tag)); diff --git a/src/verify.rs b/src/verify.rs index ffe2a04..c780781 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -43,11 +43,13 @@ pub fn verify_container( full_chunks: bool, ) -> Result { if !container_path.exists() { - bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + bail!( + "Containerdatei '{}' existiert nicht.", + container_path.display() + ); } - let db = Database::open(container_path) - .context("Konnte Container-Datenbank nicht öffnen")?; + let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?; let mut report = VerificationReport { container_path: container_path.display().to_string(), @@ -67,7 +69,8 @@ pub fn verify_container( }; // 1. SQLite B-Tree & Foreign Key Prüfung - let sqlite_issues = db.run_sqlite_integrity_check() + let sqlite_issues = db + .run_sqlite_integrity_check() .context("Fehler bei der Ausführung des SQLite integrity_check")?; if !sqlite_issues.is_empty() { report.sqlite_ok = false; @@ -96,13 +99,16 @@ pub fn verify_container( }; // 3. Node-Hierarchie & Strukturprüfung - let (dirs, files, chunks_count) = db.count_nodes_and_chunks() + let (dirs, files, chunks_count) = db + .count_nodes_and_chunks() .context("Fehler beim Zählen der Knoten und Chunks")?; report.total_dirs = dirs; report.total_files = files; report.total_chunks = chunks_count; - let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?; + let all_nodes = db + .list_all_nodes() + .context("Fehler beim Laden der Knotenliste")?; report.total_nodes = all_nodes.len(); let mut node_map = HashMap::new(); @@ -114,23 +120,33 @@ pub fn verify_container( match node_map.get(&1) { Some(root) => { if !root.is_dir { - report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string()); + report + .errors + .push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string()); } if root.parent_id.is_some() { - report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string()); + report + .errors + .push("Root-Knoten (id=1) darf keinen Parent haben!".to_string()); } } None => { - report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string()); + report + .errors + .push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string()); } } if let Some(root2) = node_map.get(&2) { if !root2.is_dir { - report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string()); + report + .errors + .push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string()); } if root2.parent_id.is_some() { - report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string()); + report + .errors + .push("Root-Knoten (id=2) darf keinen Parent haben!".to_string()); } } @@ -232,10 +248,14 @@ pub fn verify_container( }; // 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung - let chunk_headers = db.list_all_chunk_headers() + let chunk_headers = db + .list_all_chunk_headers() .context("Fehler beim Abrufen der Chunk-Liste")?; - let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2); + let format_version = meta + .as_ref() + .map(|m| m.version) + .unwrap_or(FORMAT_VERSION_V2); for (node_id, chunk_index) in chunk_headers { if !node_map.contains_key(&node_id) { @@ -319,7 +339,8 @@ mod tests { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); // Verzeichnis & Datei anlegen let folder = db.create_node(1, "photos", true).unwrap(); @@ -334,12 +355,21 @@ mod tests { let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap(); db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap(); - db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap(); + db.update_node_size_and_time( + file.id, + (chunk0_data.len() + chunk1_data.len()) as u64, + 1000, + ) + .unwrap(); db.checkpoint().unwrap(); // Verifizieren let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); - assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report); + assert!( + report.is_healthy(), + "Container must be healthy, report: {:?}", + report + ); assert_eq!(report.total_files, 1); assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos assert_eq!(report.total_chunks, 2); @@ -371,7 +401,8 @@ mod tests { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); let file = db.create_node(1, "document.pdf", false).unwrap(); let chunk_data = b"Vital documents that must not be corrupted"; @@ -387,14 +418,24 @@ mod tests { conn.execute( "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0", rusqlite::params![corrupted_ct, file.id], - ).unwrap(); + ) + .unwrap(); drop(conn); // Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven! let report = verify_container(&container_path, Some(&dek), true).expect("Verify container"); - assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot"); - assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk"); - assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler"))); + assert!( + !report.is_healthy(), + "Container must report unhealthy due to bitrot" + ); + assert_eq!( + report.corrupted_chunks, 1, + "Must detect exactly 1 corrupted chunk" + ); + assert!(report + .errors + .iter() + .any(|e| e.contains("AEAD/Integritätsfehler"))); let _ = fs::remove_file(&container_path); } diff --git a/src/vfs.rs b/src/vfs.rs index e13f6c5..650048a 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -26,14 +26,8 @@ use crate::storage::{Database, NodeRecord}; pub fn is_leak_file(filename: &str) -> bool { let lower = filename.trim().to_ascii_lowercase(); match lower.as_str() { - "thumbs.db" - | "ehthumbs.db" - | "ehthumbs_vista.db" - | "desktop.ini" - | "folder.jpg" - | "albumartsmall.jpg" - | "autorun.inf" - | ".ds_store" => true, + "thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg" + | "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true, _ => { if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) { @@ -167,7 +161,6 @@ impl SanctumFile { self.last_activity.store(now, Ordering::Relaxed); } - /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück /// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01). /// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03). @@ -179,10 +172,12 @@ impl SanctumFile { if let Some((idx, ref data, true)) = self.cached_chunk { let (ciphertext, nonce, tag) = - encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { - error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); - FsError::GeneralFailure - })?; + encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err( + |e| { + error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); + FsError::GeneralFailure + }, + )?; if let Err(e) = self.db.write_chunk_and_update_size( self.node_id, @@ -207,7 +202,10 @@ impl SanctumFile { self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); } else if self.meta.size != self.file_size { // Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat - if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) { + if let Err(e) = self + .db + .update_node_size_and_time(self.node_id, self.file_size, now) + { error!("Fehler beim Aktualisieren der Knotengröße: {e}"); return Err(FsError::GeneralFailure); } @@ -263,7 +261,10 @@ impl SanctumFile { impl Drop for SanctumFile { fn drop(&mut self) { if let Err(e) = self.flush_cached_chunk_and_size() { - warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e); + warn!( + "Fehler beim automatischen Flush im SanctumFile::drop: {:?}", + e + ); } if let Some((_, ref mut data, _)) = self.cached_chunk { data.zeroize(); @@ -354,7 +355,12 @@ impl DavFile for SanctumFile { } // Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen - if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) { + if self + .cached_chunk + .as_ref() + .map(|(_, d, _)| d.len() >= CHUNK_SIZE) + .unwrap_or(false) + { self.flush_cached_chunk_and_size()?; } @@ -633,14 +639,19 @@ impl DavFileSystem for SanctumFs { } self.touch(); - debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options); + debug!( + "VFS open aufgerufen: path='{}', options={:?}", + path_str, options + ); let existing_node = self.resolve_path(&path_str)?; let node = match existing_node { Some(n) => { // Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt! - if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) { + if self.carrier_node_id == Some(n.id) + && (options.write || options.truncate || options.append) + { return Err(FsError::Forbidden); } @@ -674,9 +685,7 @@ impl DavFileSystem for SanctumFs { } None => { if options.create || options.create_new { - let parent = self - .resolve_path(parent_path)? - .ok_or(FsError::NotFound)?; + let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); @@ -712,9 +721,7 @@ impl DavFileSystem for SanctumFs { Box::pin(async move { let path_str = Self::path_to_str(path); - let node = self - .resolve_path(&path_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); @@ -749,9 +756,7 @@ impl DavFileSystem for SanctumFs { Box::pin(async move { let path_str = Self::path_to_str(path); - let node = self - .resolve_path(&path_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; let meta = SanctumMetaData { is_dir: node.is_dir, @@ -789,9 +794,7 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Exists); } - let parent = self - .resolve_path(parent_path)? - .ok_or(FsError::NotFound)?; + let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); @@ -811,9 +814,7 @@ impl DavFileSystem for SanctumFs { Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); - let node = self - .resolve_path(&path_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); @@ -826,7 +827,11 @@ impl DavFileSystem for SanctumFs { // Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält! if let Some(carrier_id) = self.carrier_node_id { - if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? { + if self + .db + .is_descendant_of(carrier_id, node.id) + .map_err(|_| FsError::GeneralFailure)? + { return Err(FsError::Forbidden); } } @@ -847,9 +852,7 @@ impl DavFileSystem for SanctumFs { Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); - let node = self - .resolve_path(&path_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if node.is_dir { return Err(FsError::Forbidden); @@ -868,11 +871,7 @@ impl DavFileSystem for SanctumFs { }) } - fn rename<'a>( - &'a self, - from: &'a DavPath, - to: &'a DavPath, - ) -> FsFuture<'a, ()> { + fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.rename(from, to); } @@ -882,9 +881,7 @@ impl DavFileSystem for SanctumFs { let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); - let node = self - .resolve_path(&from_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?; // Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten! if self.carrier_node_id == Some(node.id) { @@ -925,19 +922,13 @@ impl DavFileSystem for SanctumFs { }) } - fn copy<'a>( - &'a self, - from: &'a DavPath, - to: &'a DavPath, - ) -> FsFuture<'a, ()> { + fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> { Box::pin(async move { self.touch(); let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); - let node = self - .resolve_path(&from_str)? - .ok_or(FsError::NotFound)?; + let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?; if node.is_dir { return Err(FsError::NotImplemented); @@ -991,9 +982,14 @@ impl DavFileSystem for SanctumFs { ) .map_err(|_| FsError::GeneralFailure)?; - let (new_ct, new_nonce, new_tag) = - encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version) - .map_err(|_| FsError::GeneralFailure)?; + let (new_ct, new_nonce, new_tag) = encrypt_chunk( + &self.dek, + dest_node.id, + idx, + &plaintext, + self.format_version, + ) + .map_err(|_| FsError::GeneralFailure)?; self.db .write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct) @@ -1025,7 +1021,9 @@ impl DavFileSystem for SanctumFs { #[cfg(test)] mod tests { use super::*; - use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION}; + use crate::crypto::{ + derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION, + }; use dav_server::fs::OpenOptions; use futures_util::StreamExt; @@ -1078,7 +1076,8 @@ mod tests { pub struct TempDir(PathBuf); impl TempDir { pub fn new() -> Self { - let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::())); + let p = + std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::())); std::fs::create_dir_all(&p).unwrap(); Self(p) } @@ -1137,7 +1136,10 @@ mod tests { // read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen let root_path = DavPath::new("/").unwrap(); - let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap(); + let mut stream = fs_shielded + .read_dir(&root_path, ReadDirMeta::None) + .await + .unwrap(); let mut names = Vec::new(); while let Some(entry) = stream.next().await { let entry = entry.unwrap(); @@ -1154,7 +1156,10 @@ mod tests { FORMAT_VERSION, false, ); - let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap(); + let mut stream_unshielded = fs_unshielded + .read_dir(&root_path, ReadDirMeta::None) + .await + .unwrap(); let mut names_unshielded = Vec::new(); while let Some(entry) = stream_unshielded.next().await { let entry = entry.unwrap(); @@ -1263,7 +1268,13 @@ mod tests { // 1. Null-Byte im Pfad let null_path = DavPath::new("/bad\0file.txt"); - assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err()); + assert!( + null_path.is_err() + || fs + .open(&null_path.unwrap(), OpenOptions::default()) + .await + .is_err() + ); assert!(validate_path_safety("/bad\0file.txt").is_err()); // 2. Steuerzeichen < 0x20 @@ -1288,7 +1299,9 @@ mod tests { // 1. Schreibe 500 Bytes let payload = Bytes::from(vec![42u8; 500]); - file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap(); + file.write_buf(Box::new(std::io::Cursor::new(payload))) + .await + .unwrap(); // 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren file.flush().await.unwrap(); diff --git a/src/windows.rs b/src/windows.rs index 7e1f501..38c07a8 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -44,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> { Command::new("explorer.exe") .arg(&drive_path) .spawn() - .with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?; + .with_context(|| { + format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path) + })?; Ok(()) } #[cfg(target_os = "macos")] @@ -73,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> { Command::new("explorer.exe") .arg(path) .spawn() - .with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?; + .with_context(|| { + format!( + "Konnte Windows Explorer für '{}' nicht öffnen", + path.display() + ) + })?; Ok(()) } #[cfg(target_os = "macos")] { - Command::new("open") - .arg(path) - .spawn() - .with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?; + Command::new("open").arg(path).spawn().with_context(|| { + format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()) + })?; Ok(()) } #[cfg(all(unix, not(target_os = "macos")))] @@ -89,7 +95,12 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> { Command::new("xdg-open") .arg(path) .spawn() - .with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?; + .with_context(|| { + format!( + "Konnte Dateimanager via xdg-open für '{}' nicht öffnen", + path.display() + ) + })?; Ok(()) } #[cfg(not(any(windows, unix)))] @@ -129,8 +140,7 @@ pub fn notify_shell_associations_changed() { let _ = Command::new("update-desktop-database").spawn(); } #[cfg(not(any(windows, all(unix, not(target_os = "macos")))))] - { - } + {} } /// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop. @@ -143,11 +153,7 @@ pub fn register_explorer_integration() -> Result<()> { let reg_commands = [ // 1. .sanctum Erweiterung mit ProgID verknüpfen - ( - r"HKCU\Software\Classes\.sanctum", - "", - "Sanctum.Container", - ), + (r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"), // 2. ProgID Metadaten & Beschreibung ( r"HKCU\Software\Classes\Sanctum.Container", @@ -205,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> { } cmd.arg("/d").arg(val_data).arg("/f"); - let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?; + let output = cmd + .output() + .with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Registry-Fehler beim Anlegen von {key}: {stderr}"); @@ -246,9 +254,7 @@ pub fn unregister_explorer_integration() -> Result<()> { ]; for key in keys_to_delete { - let _ = Command::new("reg") - .args(["delete", key, "/f"]) - .output(); + let _ = Command::new("reg").args(["delete", key, "/f"]).output(); } notify_shell_associations_changed(); @@ -258,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> { { if let Some(home) = std::env::var_os("HOME") { let home_path = std::path::PathBuf::from(home); - let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop")); - let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml")); + let _ = + std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop")); + let _ = std::fs::remove_file( + home_path.join(".local/share/mime/packages/application-x-sanctum.xml"), + ); notify_shell_associations_changed(); } Ok(()) @@ -496,9 +505,9 @@ pub fn start_session_lock_monitor( }) .context("Konnte Windows Session-Monitor-Thread nicht starten")?; - let hwnd = hwnd_rx - .recv() - .map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?; + let hwnd = hwnd_rx.recv().map_err(|e| { + anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}") + })?; if hwnd == 0 { bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden"); @@ -522,7 +531,8 @@ pub fn start_session_lock_monitor( // ───────────────────────────────────────────────────────────── #[cfg(windows)] -static CONSOLE_CTRL_TX: std::sync::Mutex>> = std::sync::Mutex::new(None); +static CONSOLE_CTRL_TX: std::sync::Mutex>> = + std::sync::Mutex::new(None); #[cfg(windows)] static CONSOLE_CTRL_DRIVE: std::sync::Mutex> = std::sync::Mutex::new(None); @@ -550,11 +560,7 @@ extern "system" { dwFlags: u32, ) -> u32; - fn WNetCancelConnection2W( - lpName: *const u16, - dwFlags: u32, - fForce: i32, - ) -> u32; + fn WNetCancelConnection2W(lpName: *const u16, dwFlags: u32, fForce: i32) -> u32; } #[cfg(windows)] @@ -571,10 +577,8 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 { if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() { if let Some(dl) = *guard { let drive_str = format!("{}:", dl.to_ascii_uppercase()); - let wide_drive: Vec = drive_str - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let wide_drive: Vec = + drive_str.encode_utf16().chain(std::iter::once(0)).collect(); unsafe { WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1); } @@ -724,9 +728,15 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R 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 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 password: Vec = session_token + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); let nr = NETRESOURCEW { dwScope: 0, @@ -750,18 +760,14 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R // 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); + 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, - ) - }; + res = unsafe { WNetAddConnection2W(&nr, password.as_ptr(), username.as_ptr(), 0) }; } if res != 0 { @@ -849,4 +855,3 @@ mod tests { drop(monitor); } } - diff --git a/tests/carrier_model_a_test.rs b/tests/carrier_model_a_test.rs index cab24b9..8a41f8a 100644 --- a/tests/carrier_model_a_test.rs +++ b/tests/carrier_model_a_test.rs @@ -7,8 +7,8 @@ use rand::rngs::OsRng; use rand::RngCore; use sanctum::crypto::{ - derive_kek, generate_dek, generate_salt, wrap_slot0_payload, - wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST, + derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams, + CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::storage::Database; use sanctum::verify::verify_container; @@ -45,8 +45,7 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { let dek_1 = generate_dek(); let carrier_node_id = 3i64; - let (wrapped_0, nonce_0, tag_0) = - wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); @@ -103,7 +102,10 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { let carrier_path = DavPath::new("/system_backup.dat").unwrap(); // Metadaten der Alibi-Datei im Decoy prüfen - let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta"); + let carrier_meta = decoy_fs + .metadata(&carrier_path) + .await + .expect("Carrier meta"); assert_eq!(carrier_meta.len(), carrier_size_bytes); assert!(!carrier_meta.is_dir()); @@ -113,20 +115,29 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { ..Default::default() }; assert!( - matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)), + matches!( + decoy_fs.open(&carrier_path, write_opts).await, + Err(FsError::Forbidden) + ), "Alibi-Datei darf nicht zum Schreiben geöffnet werden" ); // Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden assert!( - matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)), + matches!( + decoy_fs.remove_file(&carrier_path).await, + Err(FsError::Forbidden) + ), "Alibi-Datei darf nicht gelöscht werden" ); // Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden let new_name = DavPath::new("/renamed.iso").unwrap(); assert!( - matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)), + matches!( + decoy_fs.rename(&carrier_path, &new_name).await, + Err(FsError::Forbidden) + ), "Alibi-Datei darf nicht umbenannt werden" ); @@ -135,8 +146,14 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { read: true, ..Default::default() }; - let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read"); - let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk"); + let mut file_reader = decoy_fs + .open(&carrier_path, read_opts) + .await + .expect("Open read"); + let first_mb = file_reader + .read_bytes(CHUNK_SIZE) + .await + .expect("Read first chunk"); assert_eq!(first_mb.len(), CHUNK_SIZE); // 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers @@ -161,11 +178,17 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { while let Some(item) = stream.next().await { entries.push(item.unwrap().name()); } - assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein"); + assert!( + entries.is_empty(), + "Hidden Vault Wurzelverzeichnis muss anfangs leer sein" + ); // Ordner erstellen let secret_dir = DavPath::new("/Classified").unwrap(); - hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir"); + hidden_fs + .create_dir(&secret_dir) + .await + .expect("Create Classified dir"); // Datei im Ordner anlegen und schreiben let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap(); @@ -196,14 +219,24 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { .open(&secret_file_path, read_opts) .await .expect("Open secret file for read"); - let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes"); + let read_data = read_handle + .read_bytes(1024) + .await + .expect("Read secret bytes"); assert_eq!(&read_data[..], secret_content); drop(read_handle); // Größere Binärdatei schreiben (über 2 MB = 2 Blöcke) let big_file_path = DavPath::new("/Classified/payload.bin").unwrap(); let mut big_file = hidden_fs - .open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() }) + .open( + &big_file_path, + OpenOptions { + create: true, + write: true, + ..Default::default() + }, + ) .await .expect("Create big file"); @@ -211,28 +244,46 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { let mut payload = vec![0u8; payload_size]; OsRng.fill_bytes(&mut payload); - big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload"); + big_file + .write_bytes(Bytes::copy_from_slice(&payload)) + .await + .expect("Write big payload"); big_file.flush().await.expect("Flush big file"); drop(big_file); // Datei zurücklesen und Bit-für-Bit verifizieren let mut read_big = hidden_fs - .open(&big_file_path, OpenOptions { read: true, ..Default::default() }) + .open( + &big_file_path, + OpenOptions { + read: true, + ..Default::default() + }, + ) .await .expect("Open big file"); - let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes"); + let read_big_bytes = read_big + .read_bytes(payload_size + 100) + .await + .expect("Read big file bytes"); assert_eq!(read_big_bytes.len(), payload_size); assert_eq!(&read_big_bytes[..], &payload[..]); drop(read_big); // Datei umbenennen let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap(); - hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file"); + hidden_fs + .rename(&big_file_path, &renamed_path) + .await + .expect("Rename file"); assert!(hidden_fs.metadata(&big_file_path).await.is_err()); assert!(hidden_fs.metadata(&renamed_path).await.is_ok()); // Datei löschen (Blöcke werden geshreddert und freigegeben) - hidden_fs.remove_file(&renamed_path).await.expect("Remove file"); + hidden_fs + .remove_file(&renamed_path) + .await + .expect("Remove file"); assert!(hidden_fs.metadata(&renamed_path).await.is_err()); // Checkpoint SQLite @@ -284,8 +335,7 @@ async fn test_model_a_container_file_size_invariance() { let dek_1 = generate_dek(); let carrier_node_id = 3i64; - let (wrapped_0, nonce_0, tag_0) = - wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); @@ -315,7 +365,10 @@ async fn test_model_a_container_file_size_invariance() { // Initiale Dateigröße messen let initial_file_size = std::fs::metadata(&path).unwrap().len(); - assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen"); + assert!( + initial_file_size >= carrier_size_bytes, + "Containergröße muss mindestens 10 MB betragen" + ); // Hidden Mount öffnen und 4 MB geheime Daten schreiben let meta = db.read_meta().unwrap(); @@ -333,13 +386,23 @@ async fn test_model_a_container_file_size_invariance() { let test_file = DavPath::new("/large_confidential.pdf").unwrap(); let mut handle = hidden_fs - .open(&test_file, OpenOptions { create: true, write: true, ..Default::default() }) + .open( + &test_file, + OpenOptions { + create: true, + write: true, + ..Default::default() + }, + ) .await .expect("Open file"); let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB OsRng.fill_bytes(&mut random_data); - handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB"); + handle + .write_bytes(Bytes::copy_from_slice(&random_data)) + .await + .expect("Write 4MB"); handle.flush().await.expect("Flush 4MB"); drop(handle); @@ -383,8 +446,7 @@ async fn test_carrier_file_drop_and_append_mode() { let dek_1 = generate_dek(); let carrier_node_id = 3i64; - let (wrapped_0, nonce_0, tag_0) = - wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); @@ -428,44 +490,87 @@ async fn test_carrier_file_drop_and_append_mode() { // 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle) let test_file = DavPath::new("/drop_flush_test.txt").unwrap(); let mut write_handle = hidden_fs - .open(&test_file, OpenOptions { create: true, write: true, ..Default::default() }) + .open( + &test_file, + OpenOptions { + create: true, + write: true, + ..Default::default() + }, + ) .await .expect("Open file for write"); let initial_data = b"Hello from unflushed write!"; - write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data"); + write_handle + .write_bytes(Bytes::from_static(initial_data)) + .await + .expect("Write initial data"); // WICHTIG: KEIN write_handle.flush()! Nur drop: drop(write_handle); // Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden let mut read_handle = hidden_fs - .open(&test_file, OpenOptions { read: true, ..Default::default() }) + .open( + &test_file, + OpenOptions { + read: true, + ..Default::default() + }, + ) .await .expect("Open file for read"); let read_back = read_handle.read_bytes(100).await.expect("Read data back"); - assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern"); + assert_eq!( + &read_back[..], + initial_data, + "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern" + ); drop(read_handle); // 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen let append_data = b" - Appended data at EOF!"; let mut append_handle = hidden_fs - .open(&test_file, OpenOptions { write: true, append: true, ..Default::default() }) + .open( + &test_file, + OpenOptions { + write: true, + append: true, + ..Default::default() + }, + ) .await .expect("Open file for append"); - append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data"); + append_handle + .write_bytes(Bytes::from_static(append_data)) + .await + .expect("Write appended data"); drop(append_handle); // Drop sichert auch hier // Prüfe den vollständigen Dateiinhalt nach Append let mut read_handle_2 = hidden_fs - .open(&test_file, OpenOptions { read: true, ..Default::default() }) + .open( + &test_file, + OpenOptions { + read: true, + ..Default::default() + }, + ) .await .expect("Open file for read after append"); - let full_content = read_handle_2.read_bytes(200).await.expect("Read full content"); + let full_content = read_handle_2 + .read_bytes(200) + .await + .expect("Read full content"); let mut expected = Vec::new(); expected.extend_from_slice(initial_data); expected.extend_from_slice(append_data); - assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen"); + assert_eq!( + &full_content[..], + &expected[..], + "O_APPEND muss Daten am Dateiende anhängen" + ); drop(read_handle_2); let _ = std::fs::remove_file(&path); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 64f2035..9ed2342 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -10,8 +10,8 @@ use futures_util::StreamExt; use sanctum::{ crypto::{ dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, - wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, - MIN_MEMORY_COST_KIB, MIN_TIME_COST, + wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB, + MIN_TIME_COST, }, recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key}, storage::Database, @@ -22,7 +22,8 @@ use sanctum::{ #[tokio::test] async fn test_sanctum_full_container_lifecycle() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_sanctum_{}.sanctum", std::process::id())); + let container_path: PathBuf = + temp_dir.join(format!("test_sanctum_{}.sanctum", std::process::id())); // Aufräumen, falls alte Testdatei existiert if container_path.exists() { @@ -41,8 +42,7 @@ async fn test_sanctum_full_container_lifecycle() { }; let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation"); let dek = generate_dek(); - let (wrapped_dek, header_nonce, header_tag) = - wrap_dek(&kek, &dek).expect("DEK wrapping"); + let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("DEK wrapping"); let db = Database::open(&container_path).expect("Open database"); db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) @@ -57,11 +57,22 @@ async fn test_sanctum_full_container_lifecycle() { // Falsches Passwort schlägt fehl let wrong_kek = derive_kek(wrong_password, &meta.kdf_salt, &meta.kdf_params).unwrap(); - assert!(unwrap_dek(&wrong_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).is_err()); + assert!(unwrap_dek( + &wrong_kek, + &meta.wrapped_dek, + &meta.header_nonce, + &meta.header_tag + ) + .is_err()); // Richtiges Passwort entschlüsselt DEK - let unwrapped_dek = - unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).unwrap(); + let unwrapped_dek = unwrap_dek( + &kek, + &meta.wrapped_dek, + &meta.header_nonce, + &meta.header_tag, + ) + .unwrap(); assert_eq!(*dek, *unwrapped_dek); // 3. VFS Filesystem-Operationen (WebDAV Trait) @@ -80,7 +91,10 @@ async fn test_sanctum_full_container_lifecycle() { opts_write.write = true; opts_write.create_new = true; - let mut file = fs.open(&file_path, opts_write).await.expect("Open for write"); + let mut file = fs + .open(&file_path, opts_write) + .await + .expect("Open for write"); let payload_size = (2.5 * CHUNK_SIZE as f64) as usize; let mut sample_data = Vec::with_capacity(payload_size); @@ -124,7 +138,9 @@ async fn test_sanctum_full_container_lifecycle() { // Datei umbenennen let renamed_path = DavPath::new("/documents/renamed_payload.bin").unwrap(); - fs.rename(&file_path, &renamed_path).await.expect("Rename file"); + fs.rename(&file_path, &renamed_path) + .await + .expect("Rename file"); assert!(fs.metadata(&file_path).await.is_err()); let renamed_meta = fs.metadata(&renamed_path).await.expect("Metadata renamed"); @@ -137,7 +153,10 @@ async fn test_sanctum_full_container_lifecycle() { assert_eq!(copy_meta.len(), payload_size as u64); // Verzeichnis auflisten - let stream = fs.read_dir(&docs_path, ReadDirMeta::None).await.expect("Read dir"); + let stream = fs + .read_dir(&docs_path, ReadDirMeta::None) + .await + .expect("Read dir"); let entries: Vec<_> = stream.collect().await; assert_eq!(entries.len(), 2); // renamed_payload.bin & copy_payload.bin @@ -148,7 +167,10 @@ async fn test_sanctum_full_container_lifecycle() { // Prüfen, dass Root leer ist let root_path = DavPath::new("/").unwrap(); - let root_stream = fs.read_dir(&root_path, ReadDirMeta::None).await.expect("Read root"); + let root_stream = fs + .read_dir(&root_path, ReadDirMeta::None) + .await + .expect("Read root"); let root_entries: Vec<_> = root_stream.collect().await; assert_eq!(root_entries.len(), 0); @@ -165,7 +187,10 @@ async fn test_sanctum_full_container_lifecycle() { #[tokio::test] async fn test_sanctum_password_change() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_sanctum_passwd_{}.sanctum", std::process::id())); + let container_path: PathBuf = temp_dir.join(format!( + "test_sanctum_passwd_{}.sanctum", + std::process::id() + )); if container_path.exists() { let _ = std::fs::remove_file(&container_path); @@ -183,8 +208,7 @@ async fn test_sanctum_password_change() { }; let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1"); let original_dek = generate_dek(); - let (wrapped_dek_v1, nonce_v1, tag_v1) = - wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1"); + let (wrapped_dek_v1, nonce_v1, tag_v1) = wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1"); let db = Database::open(&container_path).expect("Open database"); db.init_schema(&salt_v1, &kdf_params, &wrapped_dek_v1, &nonce_v1, &tag_v1) @@ -213,36 +237,67 @@ async fn test_sanctum_password_change() { // 2. Passwortwechsel durchführen let meta_before = db.read_meta().expect("Read meta"); - let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params).expect("Derive old KEK"); - let recovered_dek = unwrap_dek(&old_kek, &meta_before.wrapped_dek, &meta_before.header_nonce, &meta_before.header_tag) - .expect("Unwrap with old password"); + let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params) + .expect("Derive old KEK"); + let recovered_dek = unwrap_dek( + &old_kek, + &meta_before.wrapped_dek, + &meta_before.header_nonce, + &meta_before.header_tag, + ) + .expect("Unwrap with old password"); let salt_v2 = generate_salt(); let kek_v2 = derive_kek(password_v2, &salt_v2, &kdf_params).expect("Derive new KEK"); - let (wrapped_dek_v2, nonce_v2, tag_v2) = wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK"); + let (wrapped_dek_v2, nonce_v2, tag_v2) = + wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK"); - db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2).expect("Update meta keys"); + db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2) + .expect("Update meta keys"); db.checkpoint().expect("Checkpoint"); // 3. Verifikation: Altes Passwort darf NICHT mehr funktionieren let meta_after = db.read_meta().expect("Read meta after"); - let old_kek_again = derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap(); + let old_kek_again = + derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap(); assert!( - unwrap_dek(&old_kek_again, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag).is_err(), + unwrap_dek( + &old_kek_again, + &meta_after.wrapped_dek, + &meta_after.header_nonce, + &meta_after.header_tag + ) + .is_err(), "Altes Passwort darf nach Passwortaenderung nicht mehr funktionieren!" ); // 4. Verifikation: Neues Passwort funktioniert und entschlüsselt alte Daten intakt - let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params).expect("Derive new KEK"); - let active_dek = unwrap_dek(&new_kek, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag) - .expect("Unwrap with new password"); + let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params) + .expect("Derive new KEK"); + let active_dek = unwrap_dek( + &new_kek, + &meta_after.wrapped_dek, + &meta_after.header_nonce, + &meta_after.header_tag, + ) + .expect("Unwrap with new password"); let fs_v2 = SanctumFs::new(db.clone(), active_dek, meta_after.version); let mut opts_read = OpenOptions::default(); opts_read.read = true; - let mut read_file = fs_v2.open(&file_path, opts_read).await.expect("Open read with new password"); - let read_bytes = read_file.read_bytes(file_data.len()).await.expect("Read bytes"); - assert_eq!(&read_bytes[..], file_data, "Daten muessen nach Passwortaenderung unveraendert lesbar sein!"); + let mut read_file = fs_v2 + .open(&file_path, opts_read) + .await + .expect("Open read with new password"); + let read_bytes = read_file + .read_bytes(file_data.len()) + .await + .expect("Read bytes"); + assert_eq!( + &read_bytes[..], + file_data, + "Daten muessen nach Passwortaenderung unveraendert lesbar sein!" + ); drop(read_file); drop(fs_v2); @@ -254,7 +309,10 @@ async fn test_sanctum_password_change() { #[tokio::test] async fn test_sanctum_lz4_compression_efficiency() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_sanctum_compress_{}.sanctum", std::process::id())); + let container_path: PathBuf = temp_dir.join(format!( + "test_sanctum_compress_{}.sanctum", + std::process::id() + )); if container_path.exists() { let _ = std::fs::remove_file(&container_path); @@ -272,12 +330,14 @@ async fn test_sanctum_lz4_compression_efficiency() { let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap"); let db = Database::open(&container_path).expect("open db"); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema"); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .expect("init schema"); let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); // 2 MB hochkompressible Textdaten erzeugen (z. B. wiederholende Zeilen) - let pattern = b"Sanctum high performance encrypted container storage with transparent LZ4 compression.\n"; + let pattern = + b"Sanctum high performance encrypted container storage with transparent LZ4 compression.\n"; let target_size = 2 * 1024 * 1024; let mut repetitive_data = Vec::with_capacity(target_size); while repetitive_data.len() < target_size { @@ -291,16 +351,24 @@ async fn test_sanctum_lz4_compression_efficiency() { opts.write = true; opts.create_new = true; let mut file = fs.open(&test_file_path, opts).await.expect("open write"); - file.write_bytes(Bytes::copy_from_slice(&repetitive_data)).await.expect("write"); + file.write_bytes(Bytes::copy_from_slice(&repetitive_data)) + .await + .expect("write"); file.flush().await.expect("flush"); drop(file); db.checkpoint().expect("checkpoint"); // Chunk-Knoten ermitteln und Ciphertext-Größe in der Datenbank prüfen - let node = db.resolve_path("/compressed_test.log").expect("resolve").expect("found"); + let node = db + .resolve_path("/compressed_test.log") + .expect("resolve") + .expect("found"); assert_eq!(node.size, target_size as u64); - let chunk0 = db.read_chunk(node.id, 0).expect("read chunk").expect("chunk 0 exists"); + let chunk0 = db + .read_chunk(node.id, 0) + .expect("read chunk") + .expect("chunk 0 exists"); // 1 MB Rohdaten komprimiert mit LZ4 sollte typischerweise < 100 KB sein assert!( chunk0.ciphertext.len() < 100_000, @@ -311,7 +379,10 @@ async fn test_sanctum_lz4_compression_efficiency() { // Datei zurücklesen und mit Original vergleichen let mut read_opts = OpenOptions::default(); read_opts.read = true; - let mut read_file = fs.open(&test_file_path, read_opts).await.expect("open read"); + let mut read_file = fs + .open(&test_file_path, read_opts) + .await + .expect("open read"); let read_back = read_file.read_bytes(target_size).await.expect("read back"); assert_eq!(read_back.len(), target_size); assert_eq!(&read_back[..], &repetitive_data[..]); @@ -325,7 +396,10 @@ async fn test_sanctum_lz4_compression_efficiency() { #[tokio::test] async fn test_sanctum_v1_backward_compatibility() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_sanctum_v1_compat_{}.sanctum", std::process::id())); + let container_path: PathBuf = temp_dir.join(format!( + "test_sanctum_v1_compat_{}.sanctum", + std::process::id() + )); if container_path.exists() { let _ = std::fs::remove_file(&container_path); @@ -343,7 +417,8 @@ async fn test_sanctum_v1_backward_compatibility() { let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap"); let db = Database::open(&container_path).expect("open db"); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema"); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .expect("init schema"); // Formatversion auf V1 setzen db.set_meta_version(FORMAT_VERSION_V1).expect("set v1"); @@ -351,13 +426,18 @@ async fn test_sanctum_v1_backward_compatibility() { assert_eq!(meta.version, FORMAT_VERSION_V1); // Datei-Knoten direkt in DB erstellen - let node = db.create_node(1, "legacy_v1.txt", false).expect("create node"); + let node = db + .create_node(1, "legacy_v1.txt", false) + .expect("create node"); // V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload."; - let (ct, nonce, tag) = encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1"); - db.write_chunk(node.id, 0, &nonce, &tag, &ct).expect("write chunk"); - db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678).expect("update size"); + let (ct, nonce, tag) = + encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1"); + db.write_chunk(node.id, 0, &nonce, &tag, &ct) + .expect("write chunk"); + db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678) + .expect("update size"); db.checkpoint().expect("checkpoint"); // Öffnen über SanctumFs konfiguriert für V1 @@ -403,7 +483,8 @@ async fn test_sanctum_disaster_recovery_workflow() { // 1. Container erstellen und Datei schreiben let db = Database::open(&container_path).expect("open db"); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema"); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .expect("init schema"); let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); let test_file = DavPath::new("/confidential.doc").unwrap(); @@ -412,7 +493,9 @@ async fn test_sanctum_disaster_recovery_workflow() { opts_w.write = true; opts_w.create_new = true; let mut f = fs.open(&test_file, opts_w).await.expect("create file"); - f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write"); + f.write_bytes(Bytes::from_static(secret_payload)) + .await + .expect("write"); f.flush().await.expect("flush"); drop(f); drop(fs); @@ -425,7 +508,8 @@ async fn test_sanctum_disaster_recovery_workflow() { assert_eq!(recovery_phrase.split_whitespace().count(), 24); // 3. Integritätsprüfung vor Katastrophe - let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial"); + let initial_report = + verify_container(&container_path, Some(&dek), true).expect("Verify initial"); assert!(initial_report.is_healthy()); assert_eq!(initial_report.total_files, 1); assert_eq!(initial_report.corrupted_chunks, 0); @@ -445,15 +529,30 @@ async fn test_sanctum_disaster_recovery_workflow() { let restored_db = Database::open(&container_path).unwrap(); let restored_meta = restored_db.read_meta().expect("Read restored meta"); - let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap(); - let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap(); + let restored_kek = derive_kek( + initial_password, + &restored_meta.kdf_salt, + &restored_meta.kdf_params, + ) + .unwrap(); + let active_dek1 = unwrap_dek( + &restored_kek, + &restored_meta.wrapped_dek, + &restored_meta.header_nonce, + &restored_meta.header_tag, + ) + .unwrap(); let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version); let mut opts_r = OpenOptions::default(); opts_r.read = true; let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored"); let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read"); - assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore"); + assert_eq!( + &read_back1[..], + secret_payload, + "Data must be intact after header restore" + ); drop(rf1); drop(fs1); drop(restored_db); @@ -470,24 +569,48 @@ async fn test_sanctum_disaster_recovery_workflow() { let rescued_db = Database::open(&container_path).unwrap(); let rescued_meta = rescued_db.read_meta().expect("Read rescued meta"); - let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap(); - let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap(); + let rescued_kek = derive_kek( + brand_new_password, + &rescued_meta.kdf_salt, + &rescued_meta.kdf_params, + ) + .unwrap(); + let active_dek2 = unwrap_dek( + &rescued_kek, + &rescued_meta.wrapped_dek, + &rescued_meta.header_nonce, + &rescued_meta.header_tag, + ) + .unwrap(); let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version); let mut opts_r2 = OpenOptions::default(); opts_r2.read = true; - let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password"); + let mut rf2 = fs2 + .open(&test_file, opts_r2) + .await + .expect("open with new password"); let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read"); - assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue"); + assert_eq!( + &read_back2[..], + secret_payload, + "Data must be intact after emergency recovery key rescue" + ); drop(rf2); drop(fs2); drop(rescued_db); // 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert let conn = rusqlite::Connection::open(&container_path).unwrap(); - let mut current_ct: Vec = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap(); + let mut current_ct: Vec = conn + .query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)) + .unwrap(); current_ct[0] ^= 0x01; // Bit-Flip - conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap(); + conn.execute( + "UPDATE chunks SET ciphertext = ?1", + rusqlite::params![current_ct], + ) + .unwrap(); drop(conn); let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot"); @@ -564,7 +687,10 @@ async fn test_anti_leak_and_inactivity_shield() { let mut opts_legit = OpenOptions::default(); opts_legit.write = true; opts_legit.create_new = true; - let mut file = fs.open(&legit_path, opts_legit).await.expect("create legit file"); + let mut file = fs + .open(&legit_path, opts_legit) + .await + .expect("create legit file"); file.write_bytes(Bytes::from_static(b"Sanctum OpSec Shield Test")) .await .expect("write data"); @@ -613,7 +739,8 @@ async fn test_anti_leak_and_inactivity_shield() { #[tokio::test] async fn test_hidden_vault_and_storage_compaction_integration() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_hidden_{}.sanctum", std::process::id())); + let container_path: PathBuf = + temp_dir.join(format!("test_hidden_{}.sanctum", std::process::id())); if container_path.exists() { let _ = std::fs::remove_file(&container_path); @@ -660,7 +787,12 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let mut auth_decoy = None; for slot in &meta.slots { if let Ok(kek) = derive_kek(password_decoy, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + if let Ok(dek) = unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.header_nonce, + &slot.header_tag, + ) { auth_decoy = Some((dek, slot.slot_id)); break; } @@ -674,7 +806,12 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let mut auth_hidden = None; for slot in &meta.slots { if let Ok(kek) = derive_kek(password_hidden, &slot.kdf_salt, &slot.kdf_params) { - if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) { + if let Ok(dek) = unwrap_dek( + &kek, + &slot.wrapped_dek, + &slot.header_nonce, + &slot.header_tag, + ) { auth_hidden = Some((dek, slot.slot_id)); break; } @@ -691,7 +828,10 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let mut opts_write = OpenOptions::default(); opts_write.write = true; opts_write.create_new = true; - let mut df = fs_decoy.open(&decoy_file_path, opts_write).await.expect("Open decoy file"); + let mut df = fs_decoy + .open(&decoy_file_path, opts_write) + .await + .expect("Open decoy file"); df.write_bytes(Bytes::from_static(b"Apples, Flour, Sugar, Butter")) .await .expect("Write decoy"); @@ -705,7 +845,10 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let mut opts_write2 = OpenOptions::default(); opts_write2.write = true; opts_write2.create_new = true; - let mut hf = fs_hidden.open(&hidden_file_path, opts_write2).await.expect("Open hidden file"); + let mut hf = fs_hidden + .open(&hidden_file_path, opts_write2) + .await + .expect("Open hidden file"); hf.write_bytes(Bytes::from_static(b"TOP SECRET INTELLIGENCE DATA")) .await .expect("Write hidden"); @@ -722,7 +865,10 @@ async fn test_hidden_vault_and_storage_compaction_integration() { decoy_entries.push(String::from_utf8_lossy(&entry.name()).to_string()); } assert_eq!(decoy_entries, vec!["harmless_recipe.txt"]); - assert!(fs_decoy.metadata(&hidden_file_path).await.is_err(), "Decoy darf classified_report nicht sehen"); + assert!( + fs_decoy.metadata(&hidden_file_path).await.is_err(), + "Decoy darf classified_report nicht sehen" + ); // Hidden-Vault sieht ebenfalls nur seine eigenen Dateien let mut stream_hidden = fs_hidden @@ -734,7 +880,10 @@ async fn test_hidden_vault_and_storage_compaction_integration() { hidden_entries.push(String::from_utf8_lossy(&entry.name()).to_string()); } assert_eq!(hidden_entries, vec!["classified_report.pdf"]); - assert!(fs_hidden.metadata(&decoy_file_path).await.is_err(), "Hidden darf harmless_recipe nicht auflösen"); + assert!( + fs_hidden.metadata(&decoy_file_path).await.is_err(), + "Hidden darf harmless_recipe nicht auflösen" + ); // 6. Forensische Dateiprüfung (Anti-Forensics / Plausible Deniability) db.checkpoint().expect("Checkpoint before raw inspection"); @@ -743,13 +892,17 @@ async fn test_hidden_vault_and_storage_compaction_integration() { // Der Dateiname "classified_report.pdf" darf NIRGENDS im Rohformat im Container stehen let needle_filename = b"classified_report"; assert!( - !raw_bytes.windows(needle_filename.len()).any(|w| w == needle_filename), + !raw_bytes + .windows(needle_filename.len()) + .any(|w| w == needle_filename), "Forensischer Leak: Dateiname des Hidden Vaults taucht als Klartext in der Datei auf!" ); let needle_payload = b"TOP SECRET INTELLIGENCE DATA"; assert!( - !raw_bytes.windows(needle_payload.len()).any(|w| w == needle_payload), + !raw_bytes + .windows(needle_payload.len()) + .any(|w| w == needle_payload), "Forensischer Leak: Nutzlast des Hidden Vaults taucht als Klartext auf!" ); @@ -759,33 +912,58 @@ async fn test_hidden_vault_and_storage_compaction_integration() { let mut opts_dump = OpenOptions::default(); opts_dump.write = true; opts_dump.create_new = true; - let mut dump_file = fs_hidden.open(&temp_large_path, opts_dump).await.expect("Open dump"); + let mut dump_file = fs_hidden + .open(&temp_large_path, opts_dump) + .await + .expect("Open dump"); let mut large_buffer = vec![0u8; 512 * 1024]; // 512 KB echte Zufallsdaten (Shannon-Entropie 8.0) rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut large_buffer); - dump_file.write_bytes(Bytes::copy_from_slice(&large_buffer)).await.expect("Write dump"); + dump_file + .write_bytes(Bytes::copy_from_slice(&large_buffer)) + .await + .expect("Write dump"); dump_file.flush().await.expect("Flush dump"); drop(dump_file); db.checkpoint().expect("Checkpoint after write"); // Datei löschen (löst automatisches Chunk-Shredding aus) - fs_hidden.remove_file(&temp_large_path).await.expect("Remove dump"); + fs_hidden + .remove_file(&temp_large_path) + .await + .expect("Remove dump"); db.checkpoint().expect("Checkpoint after remove"); let free_pages = db.freelist_count().expect("freelist count"); - assert!(free_pages > 0, "Nach dem Löschen müssen freie Seiten in der Freelist existieren"); + assert!( + free_pages > 0, + "Nach dem Löschen müssen freie Seiten in der Freelist existieren" + ); // Incremental Vacuum ausführen let reclaimed = db.incremental_vacuum(None).expect("incremental vacuum"); - assert!(reclaimed > 0, "Seiten müssen an das Dateisystem zurückgegeben werden"); - assert_eq!(db.freelist_count().unwrap(), 0, "Freelist muss jetzt 0 sein"); + assert!( + reclaimed > 0, + "Seiten müssen an das Dateisystem zurückgegeben werden" + ); + assert_eq!( + db.freelist_count().unwrap(), + 0, + "Freelist muss jetzt 0 sein" + ); // Verifiziere, dass verbleibende Dateien in beiden Vaults intakt lesbar sind - let mut r_decoy = fs_decoy.open(&decoy_file_path, OpenOptions::default()).await.expect("open decoy"); + let mut r_decoy = fs_decoy + .open(&decoy_file_path, OpenOptions::default()) + .await + .expect("open decoy"); let d_data = r_decoy.read_bytes(30).await.expect("read decoy"); assert_eq!(&d_data[..], b"Apples, Flour, Sugar, Butter"); - let mut r_hidden = fs_hidden.open(&hidden_file_path, OpenOptions::default()).await.expect("open hidden"); + let mut r_hidden = fs_hidden + .open(&hidden_file_path, OpenOptions::default()) + .await + .expect("open hidden"); let h_data = r_hidden.read_bytes(30).await.expect("read hidden"); assert_eq!(&h_data[..], b"TOP SECRET INTELLIGENCE DATA"); @@ -796,9 +974,14 @@ async fn test_hidden_vault_and_storage_compaction_integration() { #[tokio::test] async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() { let temp_dir = std::env::temp_dir(); - let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id())); - let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id())); - let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id())); + let path_standard: PathBuf = + temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id())); + let path_dual: PathBuf = + temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id())); + let backup_path: PathBuf = temp_dir.join(format!( + "test_denial_dual_{}.sanctum.hdr", + std::process::id() + )); if path_standard.exists() { let _ = std::fs::remove_file(&path_standard); @@ -825,7 +1008,9 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap(); let db_std = Database::open(&path_standard).unwrap(); - db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap(); + db_std + .init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std) + .unwrap(); db_std.checkpoint().unwrap(); // 2. Erstelle Dual-Vault Container (Container B) @@ -839,10 +1024,16 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap(); let db_dual = Database::open(&path_dual).unwrap(); - db_dual.init_schema_with_hidden( - &salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0, - Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)), - ).unwrap(); + db_dual + .init_schema_with_hidden( + &salt_b0, + &kdf_params, + &wrapped_b0, + &nonce_b0, + &tag_b0, + Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)), + ) + .unwrap(); db_dual.checkpoint().unwrap(); // 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit @@ -851,23 +1042,51 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() // A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] { - let v_nodes: i64 = conn.query_row( - "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0) - ).unwrap(); - assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name); + let v_nodes: i64 = conn + .query_row( + "SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + v_nodes, 0, + "{} Container darf keine vault_id in nodes haben", + name + ); - let v_chunks: i64 = conn.query_row( - "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0) - ).unwrap(); - assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name); + let v_chunks: i64 = conn + .query_row( + "SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + v_chunks, 0, + "{} Container darf keine vault_id in chunks haben", + name + ); // Genau 2 Slots in meta - let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap(); + let slot_count: i64 = conn + .query_row("SELECT count(*) FROM meta", [], |r| r.get(0)) + .unwrap(); assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name); // Genau 2 Root-Nodes in nodes (id=1 und id=2) - let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap(); - assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name); + let root_count: i64 = conn + .query_row( + "SELECT count(*) FROM nodes WHERE parent_id IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + root_count, 2, + "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", + name + ); } drop(conn_std); drop(conn_dual); @@ -884,12 +1103,18 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() opts_w.create_new = true; let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap(); - f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap(); + f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")) + .await + .unwrap(); f_d.flush().await.unwrap(); drop(f_d); let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap(); - f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap(); + f_h.write_bytes(Bytes::from_static( + b"My deepest personal thoughts and secrets.", + )) + .await + .unwrap(); f_h.flush().await.unwrap(); drop(f_h); @@ -897,17 +1122,37 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() // 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap(); - let hidden_node_name: String = conn_dual2.query_row( - "SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0) - ).unwrap(); - assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!"); - assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!"); - assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein"); + let hidden_node_name: String = conn_dual2 + .query_row( + "SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + !hidden_node_name.starts_with("$h$"), + "Hidden Dateiname darf keinesfalls mit $h$ beginnen!" + ); + assert!( + !hidden_node_name.contains("journal"), + "Klartext darf keinesfalls in SQLite auftauchen!" + ); + assert!( + hidden_node_name.len() >= 56, + "Verschlüsselter Name muss ein gültiger Hex-String sein" + ); - let count_dollar_h: i64 = conn_dual2.query_row( - "SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0) - ).unwrap(); - assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren"); + let count_dollar_h: i64 = conn_dual2 + .query_row( + "SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count_dollar_h, 0, + "Es darf kein einziger Knoten mit $h$ existieren" + ); drop(conn_dual2); // 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG @@ -925,13 +1170,27 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() // 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT // Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden! let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap(); - assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors); - assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden"); - assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden"); + assert!( + report_decoy.is_healthy(), + "Decoy verify muss gesund sein! Fehler: {:?}", + report_decoy.errors + ); + assert_eq!( + report_decoy.corrupted_chunks, 0, + "Decoy verify darf keine korrupten Chunks melden" + ); + assert_eq!( + report_decoy.orphan_nodes, 0, + "Decoy verify darf Root 2 nicht als verwaist melden" + ); // Hidden-Prüfung: Ebenfalls gesund! let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap(); - assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors); + assert!( + report_hidden.is_healthy(), + "Hidden verify muss gesund sein! Fehler: {:?}", + report_hidden.errors + ); assert_eq!(report_hidden.corrupted_chunks, 0); assert_eq!(report_hidden.orphan_nodes, 0); @@ -948,24 +1207,33 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() // Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos! let restored_meta = db_dual.read_meta().unwrap(); - let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore"); + let res_decoy = restored_meta + .authenticate(pass_decoy) + .expect("Decoy after backup restore"); assert_eq!(res_decoy.2, 0); - let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore"); + let res_hidden = restored_meta + .authenticate(pass_hidden) + .expect("Hidden after backup restore"); assert_eq!(res_hidden.2, 1); // 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört! let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap(); let brand_new_decoy_pass = "BrandNewDecoyPassword2026!"; - restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap(); + restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass) + .unwrap(); let rescued_meta = db_dual.read_meta().unwrap(); // Neues Decoy Passwort entsperrt Slot 0 - let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass"); + let rescued_decoy = rescued_meta + .authenticate(brand_new_decoy_pass) + .expect("New decoy pass"); assert_eq!(rescued_decoy.2, 0); assert_eq!(*rescued_decoy.0, *dek_b0); // Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!) - let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved"); + let rescued_hidden = rescued_meta + .authenticate(pass_hidden) + .expect("Hidden pass preserved"); assert_eq!(rescued_hidden.2, 1); assert_eq!(*rescued_hidden.0, *dek_b1); @@ -979,8 +1247,14 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() async fn test_sanctum_online_backup_and_restore() { let temp_dir = std::env::temp_dir(); let container_path = temp_dir.join(format!("test_backup_src_{}.sanctum", std::process::id())); - let backup_path = temp_dir.join(format!("test_backup_out_{}.sanctum.bak", std::process::id())); - let restored_path = temp_dir.join(format!("test_backup_restored_{}.sanctum", std::process::id())); + let backup_path = temp_dir.join(format!( + "test_backup_out_{}.sanctum.bak", + std::process::id() + )); + let restored_path = temp_dir.join(format!( + "test_backup_restored_{}.sanctum", + std::process::id() + )); for p in [&container_path, &backup_path, &restored_path] { if p.exists() { @@ -1000,47 +1274,97 @@ async fn test_sanctum_online_backup_and_restore() { let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .unwrap(); db.checkpoint().unwrap(); // Datei schreiben let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); let test_file = DavPath::new("/important.txt").unwrap(); - let mut file = fs.open(&test_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap(); - file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")).await.unwrap(); + let mut file = fs + .open( + &test_file, + OpenOptions { + write: true, + create_new: true, + ..Default::default() + }, + ) + .await + .unwrap(); + file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")) + .await + .unwrap(); file.flush().await.unwrap(); drop(file); // 1. Online-Live-Backup erstellen - db.online_backup(&backup_path).expect("Online backup should succeed"); + db.online_backup(&backup_path) + .expect("Online backup should succeed"); assert!(backup_path.exists(), "Backup-Datei muss existieren"); // 2. Original-Container verändern (neue Datei hinzufügen) let extra_file = DavPath::new("/extra_after_backup.txt").unwrap(); - let mut file2 = fs.open(&extra_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap(); - file2.write_bytes(Bytes::from_static(b"After Backup Data")).await.unwrap(); + let mut file2 = fs + .open( + &extra_file, + OpenOptions { + write: true, + create_new: true, + ..Default::default() + }, + ) + .await + .unwrap(); + file2 + .write_bytes(Bytes::from_static(b"After Backup Data")) + .await + .unwrap(); file2.flush().await.unwrap(); drop(file2); // 3. Restore aus dem Backup in neuen Pfad Database::restore_from_backup(&backup_path, &restored_path).expect("Restore should succeed"); - assert!(restored_path.exists(), "Wiederhergestellter Container muss existieren"); + assert!( + restored_path.exists(), + "Wiederhergestellter Container muss existieren" + ); // 4. Verifiziere den wiederhergestellten Container let restored_db = Database::open(&restored_path).unwrap(); let meta = restored_db.read_meta().unwrap(); - let auth = meta.authenticate(password).expect("Passwort muss den wiederhergestellten Container entsperren"); + let auth = meta + .authenticate(password) + .expect("Passwort muss den wiederhergestellten Container entsperren"); assert_eq!(*auth.0, *dek); let restored_fs = SanctumFs::new(restored_db.clone(), auth.0, meta.version); // /important.txt muss existieren und den korrekten Inhalt haben - let mut read_handle = restored_fs.open(&test_file, OpenOptions { read: true, ..Default::default() }).await.unwrap(); + let mut read_handle = restored_fs + .open( + &test_file, + OpenOptions { + read: true, + ..Default::default() + }, + ) + .await + .unwrap(); let content = read_handle.read_bytes(100).await.unwrap(); assert_eq!(&content[..], b"Sanctum Online Backup Test Data"); drop(read_handle); // /extra_after_backup.txt darf im Backup-Zustand NICHT existieren - assert!(restored_fs.open(&extra_file, OpenOptions { read: true, ..Default::default() }).await.is_err()); + assert!(restored_fs + .open( + &extra_file, + OpenOptions { + read: true, + ..Default::default() + } + ) + .await + .is_err()); // Integritätsprüfung (FSCK) auf wiederhergestelltem Container let report = verify_container(&restored_path, Some(&dek), true).unwrap(); @@ -1089,7 +1413,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() { let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .unwrap(); db.checkpoint().unwrap(); let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION); @@ -1114,7 +1439,10 @@ async fn test_webdav_loopback_session_token_and_host_validation() { // 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()); + let req = format!( + "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + addr.port() + ); stream.write_all(req.as_bytes()).await.unwrap(); let mut resp = Vec::new(); stream.read_to_end(&mut resp).await.unwrap(); @@ -1125,7 +1453,9 @@ async fn test_webdav_loopback_session_token_and_host_validation() { resp_str ); assert!( - resp_str.to_lowercase().contains("www-authenticate: basic realm=\"sanctum\""), + resp_str + .to_lowercase() + .contains("www-authenticate: basic realm=\"sanctum\""), "401-Antwort muss WWW-Authenticate Header enthalten, erhalten:\n{}", resp_str ); @@ -1134,7 +1464,11 @@ async fn test_webdav_loopback_session_token_and_host_validation() { // 4. Angriffstest B: DNS-Rebinding mit fremdem Host-Header trotz erratenem Token -> 403 Forbidden { let mut stream = TcpStream::connect(addr).await.unwrap(); - let req = format!("GET /{}/ HTTP/1.1\r\nHost: evil.attacker.com:{}\r\nConnection: close\r\n\r\n", session_token, addr.port()); + let req = format!( + "GET /{}/ HTTP/1.1\r\nHost: evil.attacker.com:{}\r\nConnection: close\r\n\r\n", + session_token, + addr.port() + ); stream.write_all(req.as_bytes()).await.unwrap(); let mut resp = Vec::new(); stream.read_to_end(&mut resp).await.unwrap(); @@ -1146,17 +1480,54 @@ async fn test_webdav_loopback_session_token_and_host_validation() { ); } - // 5. Legitimer Zugriff: Korrektes Session-Token via Path-Prefix Fallback -> 200 OK + // 5. SA-05 Angriffstest C: Session-Token in URI (Pfad oder Query) MUSS mit 403 Forbidden abgewiesen werden + { + // a) Token im URI-Pfad + let mut stream = TcpStream::connect(addr).await.unwrap(); + let req = format!( + "OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + session_token, + addr.port() + ); + stream.write_all(req.as_bytes()).await.unwrap(); + let mut resp = Vec::new(); + stream.read_to_end(&mut resp).await.unwrap(); + let resp_str = String::from_utf8_lossy(&resp); + assert!( + resp_str.starts_with("HTTP/1.1 403 Forbidden"), + "Anfrage mit Session-Token im Pfad muss mit 403 Forbidden abgewiesen werden (SA-05), erhalten:\n{}", + resp_str + ); + + // b) Token im Query-String + let mut stream_query = TcpStream::connect(addr).await.unwrap(); + let req_query = format!( + "OPTIONS /?token={} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + session_token, + addr.port() + ); + stream_query.write_all(req_query.as_bytes()).await.unwrap(); + let mut resp_query = Vec::new(); + stream_query.read_to_end(&mut resp_query).await.unwrap(); + let resp_query_str = String::from_utf8_lossy(&resp_query); + assert!( + resp_query_str.starts_with("HTTP/1.1 403 Forbidden"), + "Anfrage mit Session-Token in Query muss mit 403 Forbidden abgewiesen werden (SA-05), erhalten:\n{}", + resp_query_str + ); + } + + // 6. Legitimer Zugriff A (SA-05): X-Sanctum-Token Header -> 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()); + let req = format!("OPTIONS / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nX-Sanctum-Token: {}\r\nConnection: close\r\n\r\n", addr.port(), session_token); stream.write_all(req.as_bytes()).await.unwrap(); let mut resp = Vec::new(); stream.read_to_end(&mut resp).await.unwrap(); let resp_str = String::from_utf8_lossy(&resp); assert!( resp_str.starts_with("HTTP/1.1 200 OK"), - "Legitime OPTIONS-Anfrage mit Session-Token muss 200 OK liefern, erhalten:\n{}", + "Legitime OPTIONS-Anfrage mit X-Sanctum-Token Header muss 200 OK liefern, erhalten:\n{}", resp_str ); assert!( @@ -1165,10 +1536,13 @@ async fn test_webdav_loopback_session_token_and_host_validation() { ); } - // 6. Legitimer Zugriff: HTTP Basic Auth (R-05) -> 200 OK + // 7. Legitimer Zugriff B (SA-05 & R-05): HTTP Basic Auth -> 200 OK { use base64::Engine; - let auth_hdr = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))); + 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(); diff --git a/tests/live_crash_resilience_test.rs b/tests/live_crash_resilience_test.rs index 9799d2d..3766c05 100644 --- a/tests/live_crash_resilience_test.rs +++ b/tests/live_crash_resilience_test.rs @@ -20,7 +20,10 @@ use sanctum::{ #[tokio::test] async fn test_live_crash_and_recovery_stress() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id())); + let container_path: PathBuf = temp_dir.join(format!( + "sanctum_live_stress_{}.sanctum", + std::process::id() + )); if container_path.exists() { let _ = std::fs::remove_file(&container_path); } @@ -105,13 +108,17 @@ async fn test_live_crash_and_recovery_stress() { // 4. Recovery & Integritätsprüfung nach Crash // Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten - let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash"); + let verify_result = + verify_container(&container_path, Some(&dek), false).expect("Verify post-crash"); assert!( verify_result.is_healthy(), "Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}", verify_result.errors ); - assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt"); + assert_eq!( + verify_result.corrupted_chunks, 0, + "Keine korrupten Chunks erlaubt" + ); // 5. Konsistentes Weiterarbeiten nach dem Absturz let db_recovered = Database::open(&container_path).expect("Open database after crash"); diff --git a/tests/mount_security_test.rs b/tests/mount_security_test.rs index 51626f1..5b83145 100644 --- a/tests/mount_security_test.rs +++ b/tests/mount_security_test.rs @@ -1,10 +1,10 @@ -use std::path::PathBuf; use sanctum::crypto::{ derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::mount::is_loopback_host; use sanctum::storage::Database; +use std::path::PathBuf; #[test] fn test_is_loopback_host_comprehensive() { @@ -45,7 +45,8 @@ fn test_is_loopback_host_comprehensive() { #[test] fn test_wal_and_shm_cleanup_on_close() { let temp_dir = std::env::temp_dir(); - let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id())); + let container_path: PathBuf = + temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id())); if container_path.exists() { let _ = std::fs::remove_file(&container_path); } @@ -61,8 +62,9 @@ fn test_wal_and_shm_cleanup_on_close() { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&container_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); - + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); + // Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen let _ = db.create_node(1, "test_file.txt", false).unwrap(); db.checkpoint().unwrap(); @@ -77,24 +79,33 @@ fn test_wal_and_shm_cleanup_on_close() { shm.push("-shm"); let _ = std::fs::remove_file(&shm); - assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben"); - assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben"); + assert!( + !PathBuf::from(wal).exists(), + "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben" + ); + assert!( + !PathBuf::from(shm).exists(), + "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben" + ); let _ = std::fs::remove_file(&container_path); } #[test] fn test_mount_security_multi_auth_and_no_token_in_url() { - use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix}; + use sanctum::mount::{check_basic_auth, check_token_header, uri_contains_token}; let session_token = "4f8a12bc90de45f187a23456789abcde"; let port = 8443; let remote_url = format!("http://127.0.0.1:{}/", port); - // R-05: 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"); + // R-05 & SA-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten! + assert!( + !remote_url.contains(session_token), + "Remote URL darf niemals das Session-Token enthalten" + ); - // 1. Basic Auth Prüfung + // 1. Basic Auth Prüfung (Constant-Time) use base64::Engine; let auth_header = format!( "Basic {}", @@ -102,14 +113,44 @@ fn test_mount_security_multi_auth_and_no_token_in_url() { ); assert!(check_basic_auth(&auth_header, session_token)); - // 2. Token Header Prüfung + let wrong_auth = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("sanctum:wrong_token_1234567890abcdef") + ); + assert!(!check_basic_auth(&wrong_auth, session_token)); + + // 2. Token Header Prüfung (Constant-Time) let mut headers = hyper::HeaderMap::new(); headers.insert("X-Sanctum-Token", session_token.parse().unwrap()); assert!(check_token_header(&headers, session_token)); - // 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"); + let mut wrong_headers = hyper::HeaderMap::new(); + wrong_headers.insert( + "X-Sanctum-Token", + "wrong_token_1234567890abcdef".parse().unwrap(), + ); + assert!(!check_token_header(&wrong_headers, session_token)); + + // 3. SA-05: Verifikation, dass Token in URI (Pfad oder Query) erkannt und strikt abgewiesen wird + let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token) + .parse() + .unwrap(); + assert!( + uri_contains_token(&uri_path, session_token), + "Token im Pfad muss erkannt werden" + ); + + let uri_query: hyper::Uri = format!("http://127.0.0.1:8443/test.txt?token={}", session_token) + .parse() + .unwrap(); + assert!( + uri_contains_token(&uri_query, session_token), + "Token im Query-String muss erkannt werden" + ); + + let uri_clean: hyper::Uri = "http://127.0.0.1:8443/test.txt".parse().unwrap(); + assert!( + !uri_contains_token(&uri_clean, session_token), + "Saubere URI darf kein Token enthalten" + ); } diff --git a/tests/path_traversal_test.rs b/tests/path_traversal_test.rs index e6c0699..9e2ce3e 100644 --- a/tests/path_traversal_test.rs +++ b/tests/path_traversal_test.rs @@ -27,9 +27,25 @@ fn test_validate_node_name_rejections() { // 6. Windows reservierte Gerätenamen let reserved_names = [ - "CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL", - "COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9", - "con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf", + "CON", + "con", + "prn", + "PRN", + "aux", + "AUX", + "nul", + "NUL", + "COM1", + "com2", + "COM9", + "lpt1", + "LPT2", + "LPT9", + "con.txt", + "aux.dat", + "NUL.tar.gz", + "com1.log", + "prn.pdf", ]; for res in reserved_names { assert!( @@ -64,7 +80,10 @@ 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())); + path.push(format!( + "sanctum_test_pathutil_{}.sanctum", + std::process::id() + )); if path.exists() { let _ = std::fs::remove_file(&path); } @@ -80,20 +99,31 @@ fn test_storage_create_and_rename_reject_invalid_names() { 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(); + 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()); + 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(); + 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()); + 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 5104b69..450bf7f 100644 --- a/tests/sync_carrier_protection_test.rs +++ b/tests/sync_carrier_protection_test.rs @@ -1,11 +1,11 @@ -use std::collections::HashSet; -use std::path::PathBuf; use rand::rngs::OsRng; use rand::RngCore; +use std::collections::HashSet; +use std::path::PathBuf; use sanctum::crypto::{ - derive_kek, generate_dek, generate_salt, wrap_slot0_payload, - wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST, + derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams, + MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::storage::Database; use sanctum::sync::{delete_orphans_in_vault, SyncStats}; @@ -41,8 +41,7 @@ fn test_carrier_protection_in_storage_and_sync() { let dek_1 = generate_dek(); let carrier_node_id = 3i64; - let (wrapped_0, nonce_0, tag_0) = - wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); @@ -74,18 +73,33 @@ fn test_carrier_protection_in_storage_and_sync() { // 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden let del_res = db.delete_node(carrier_node_id); - assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen"); + assert!( + del_res.is_err(), + "Löschen der Trägerdatei muss fehlschlagen" + ); assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz")); // 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0); - assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen"); - assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz")); + assert!( + rename_res.is_err(), + "Umbenennen der Trägerdatei muss fehlschlagen" + ); + assert!(rename_res + .unwrap_err() + .to_string() + .contains("Carrier-Schutz")); // 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden let trunc_res = db.truncate_chunks_after(carrier_node_id, 0); - assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen"); - assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz")); + assert!( + trunc_res.is_err(), + "Truncate der Trägerdatei muss fehlschlagen" + ); + assert!(trunc_res + .unwrap_err() + .to_string() + .contains("Carrier-Schutz")); // 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set: // Der Carrier darf unter keinen Umständen gelöscht werden! @@ -102,16 +116,26 @@ fn test_carrier_protection_in_storage_and_sync() { true, // quiet &mut stats, ); - assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen"); - assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein"); + assert!( + orphan_res.is_ok(), + "delete_orphans_in_vault muss ohne Fehler durchlaufen" + ); + assert_eq!( + stats.files_deleted, 0, + "Carrier-Datei darf nicht gelöscht worden sein" + ); // Sicherstellen, dass Carrier nach wie vor in der DB existiert let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap(); - assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein"); + 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 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, @@ -122,11 +146,18 @@ fn test_carrier_protection_in_storage_and_sync() { 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"); + 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 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, @@ -144,9 +175,15 @@ fn test_carrier_protection_in_storage_and_sync() { &local_pull_dir.to_string_lossy(), &sync_options, ); - assert!(pull_res.is_ok(), "run_sync pull muss erfolgreich durchlaufen"); + 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"); + 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 @@ -166,14 +203,22 @@ fn test_carrier_protection_in_storage_and_sync() { 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"); + 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 _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"); + assert!( + copy_dst_res.is_err(), + "SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern" + ); let _ = std::fs::remove_file(&path); } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index e217c9d..9ecc892 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -22,13 +22,15 @@ fn create_test_container(path: &Path) -> (Database, [u8; 32]) { let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap(); let dek = generate_dek(); let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + .unwrap(); (db, *dek) } #[test] fn test_sync_push_and_pull_basic() { - let temp_root = std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::())); + let temp_root = + std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let source_dir = temp_root.join("source"); let target_dir = temp_root.join("restored"); @@ -38,7 +40,11 @@ fn test_sync_push_and_pull_basic() { // Testdateien anlegen fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap(); - fs::write(source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000]).unwrap(); + fs::write( + source_dir.join("sub").join("file2.bin"), + vec![0x42u8; 100_000], + ) + .unwrap(); let (db, dek) = create_test_container(&container_path); @@ -55,7 +61,8 @@ fn test_sync_push_and_pull_basic() { source_dir.to_str().unwrap(), "/Backup", &opts, - ).expect("Sync push"); + ) + .expect("Sync push"); assert_eq!(stats.files_scanned, 2); assert_eq!(stats.files_transferred, 2); @@ -70,7 +77,8 @@ fn test_sync_push_and_pull_basic() { source_dir.to_str().unwrap(), "/Backup", &opts, - ).expect("Sync push delta"); + ) + .expect("Sync push delta"); assert_eq!(stats_delta.files_transferred, 0); assert_eq!(stats_delta.files_skipped, 2); @@ -88,13 +96,16 @@ fn test_sync_push_and_pull_basic() { source_dir.to_str().unwrap(), "/Backup", &dry_opts, - ).expect("Sync push dry-run"); + ) + .expect("Sync push dry-run"); assert_eq!(stats_dry.files_transferred, 1); assert_eq!(stats_dry.files_skipped, 2); // Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert - let node = db.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek).unwrap(); + let node = db + .resolve_path_in_vault("/Backup/new_file.txt", 0, &dek) + .unwrap(); assert!(node.is_none()); // 4. Pull @@ -110,7 +121,8 @@ fn test_sync_push_and_pull_basic() { "/Backup", target_dir.to_str().unwrap(), &pull_opts, - ).expect("Sync pull"); + ) + .expect("Sync pull"); assert_eq!(stats_pull.files_transferred, 2); @@ -126,7 +138,8 @@ fn test_sync_push_and_pull_basic() { #[test] fn test_sync_multi_megabyte_large_file() { - let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::())); + let temp_root = + std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::())); let container_path = temp_root.join("test_large.sanctum"); let source_dir = temp_root.join("source"); let target_dir = temp_root.join("restored"); @@ -159,10 +172,14 @@ fn test_sync_multi_megabyte_large_file() { source_dir.to_str().unwrap(), "/LargeTest", &opts, - ).expect("Sync push large"); + ) + .expect("Sync push large"); assert_eq!(stats.files_transferred, 1); - assert_eq!(stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024); + assert_eq!( + stats.bytes_transferred, + 3 * (CHUNK_SIZE as u64) + 512 * 1024 + ); // Pull let mut pull_opts = SyncOptions::default(); @@ -177,7 +194,8 @@ fn test_sync_multi_megabyte_large_file() { "/LargeTest", target_dir.to_str().unwrap(), &pull_opts, - ).expect("Sync pull large"); + ) + .expect("Sync pull large"); let restored = fs::read(target_dir.join("large_payload.bin")).unwrap(); let original = fs::read(&large_file_path).unwrap(); @@ -189,7 +207,8 @@ fn test_sync_multi_megabyte_large_file() { #[test] fn test_sync_delete_and_exclude_flags() { - let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::())); + let temp_root = + std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::())); let container_path = temp_root.join("test_flags.sanctum"); let source_dir = temp_root.join("source"); @@ -214,10 +233,14 @@ fn test_sync_delete_and_exclude_flags() { source_dir.to_str().unwrap(), "/Files", &opts, - ).unwrap(); + ) + .unwrap(); assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt - assert!(db.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek).unwrap().is_none()); + assert!(db + .resolve_path_in_vault("/Files/ignore.tmp", 0, &dek) + .unwrap() + .is_none()); // 2. Lokale Datei löschen und Sync mit --delete ausführen fs::remove_file(source_dir.join("remove_me.txt")).unwrap(); @@ -231,11 +254,18 @@ fn test_sync_delete_and_exclude_flags() { source_dir.to_str().unwrap(), "/Files", &opts, - ).unwrap(); + ) + .unwrap(); assert_eq!(stats_del.files_deleted, 1); - assert!(db.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek).unwrap().is_none()); - assert!(db.resolve_path_in_vault("/Files/keep.txt", 0, &dek).unwrap().is_some()); + assert!(db + .resolve_path_in_vault("/Files/remove_me.txt", 0, &dek) + .unwrap() + .is_none()); + assert!(db + .resolve_path_in_vault("/Files/keep.txt", 0, &dek) + .unwrap() + .is_some()); let _ = fs::remove_dir_all(&temp_root); } diff --git a/tests/untrusted_container_test.rs b/tests/untrusted_container_test.rs index e4090bd..6a10a96 100644 --- a/tests/untrusted_container_test.rs +++ b/tests/untrusted_container_test.rs @@ -1,9 +1,9 @@ -use std::path::PathBuf; use sanctum::crypto::{ derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::storage::Database; +use std::path::PathBuf; #[test] fn test_reject_arbitrary_sqlite_database() { @@ -16,12 +16,10 @@ fn test_reject_arbitrary_sqlite_database() { // Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten { let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute( - "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", - [], - ) - .unwrap(); - conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap(); + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", []) + .unwrap(); + conn.execute("INSERT INTO users (name) VALUES ('Alice')", []) + .unwrap(); } // Sanctum Database::open muss die Datei sofort fail-closed ablehnen @@ -114,7 +112,8 @@ fn test_reject_out_of_bounds_kdf_params() { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&db_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); db.checkpoint().unwrap(); drop(db); @@ -169,7 +168,8 @@ fn test_reject_out_of_bounds_kdf_params() { #[test] fn test_reject_corrupted_slot_lengths() { let temp_dir = std::env::temp_dir(); - let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id())); + let db_path: PathBuf = + temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id())); if db_path.exists() { let _ = std::fs::remove_file(&db_path); } @@ -185,7 +185,8 @@ fn test_reject_corrupted_slot_lengths() { let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); let db = Database::open(&db_path).unwrap(); - db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); db.checkpoint().unwrap(); drop(db); @@ -214,3 +215,465 @@ fn test_reject_corrupted_slot_lengths() { let _ = std::fs::remove_file(&db_path); } + +#[test] +fn test_reject_slot_amplification_and_excessive_slots_dos() { + let temp_dir = std::env::temp_dir(); + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + let params_json = serde_json::to_string(&kdf_params).unwrap(); + + // 1. Fall: 0 Slots (leere meta-Tabelle) + { + let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", []) + .unwrap(); + conn.execute("CREATE TABLE chunks (node_id INTEGER);", []) + .unwrap(); + } + let db = Database::open(&db_path).unwrap(); + let res = db.read_slots(); + assert!(res.is_err()); + let err_msg = res.err().unwrap().to_string(); + assert!( + err_msg.contains("leer oder beschädigt"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } + + // 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein + { + let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + let db = Database::open(&db_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); + // Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute("DELETE FROM meta WHERE slot_id = 1", []) + .unwrap(); + } + let db_reopened = Database::open(&db_path).unwrap(); + let slots = db_reopened + .read_slots() + .expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden"); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].slot_id, 0); + let _ = std::fs::remove_file(&db_path); + } + + // 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein + { + let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + let db = Database::open(&db_path).unwrap(); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); + let slots = db + .read_slots() + .expect("2 gültige Slots müssen erfolgreich gelesen werden"); + assert_eq!(slots.len(), 2); + assert_eq!(slots[0].slot_id, 0); + assert_eq!(slots[1].slot_id, 1); + let _ = std::fs::remove_file(&db_path); + } + + // 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden + { + let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + for i in 0..3 { + conn.execute( + "INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + i, + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + } + } + let open_res = Database::open(&db_path); + assert!( + open_res.is_err(), + "Database::open muss 3 Slots sofort abweisen" + ); + let err_msg = open_res.err().unwrap().to_string(); + assert!( + err_msg.contains("Ungültige Slot-Anzahl"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } + + // 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden + { + let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + for i in 0..100 { + conn.execute( + "INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + i, + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + } + } + let open_res = Database::open(&db_path); + assert!( + open_res.is_err(), + "100 Slots müssen sofort abgewiesen werden" + ); + let _ = std::fs::remove_file(&db_path); + } + + // 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden + { + let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + let mut stmt = conn + .prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)") + .unwrap(); + for i in 0..1000 { + stmt.execute(rusqlite::params![ + i, + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ]) + .unwrap(); + } + } + let open_res = Database::open(&db_path); + assert!( + open_res.is_err(), + "1000 Slots müssen sofort abgewiesen werden" + ); + let err_msg = open_res.err().unwrap().to_string(); + assert!( + err_msg.contains("Ungültige Slot-Anzahl"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } +} + +#[test] +fn test_reject_duplicate_and_invalid_slot_ids() { + let temp_dir = std::env::temp_dir(); + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + let params_json = serde_json::to_string(&kdf_params).unwrap(); + + // 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0) + { + let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + } + let db = Database::open(&db_path).unwrap(); + let res = db.read_slots(); + assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden"); + let err_msg = res.err().unwrap().to_string(); + assert!( + err_msg.contains("Doppelte Slot-ID 0"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } + + // 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1) + { + let db_path = temp_dir.join(format!( + "test_invalid_slot_id_{}.sanctum", + std::process::id() + )); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + } + let db = Database::open(&db_path).unwrap(); + let res = db.read_slots(); + assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden"); + let err_msg = res.err().unwrap().to_string(); + assert!( + err_msg.contains("Ungültige Slot-ID 5"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } + + // 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden) + { + let db_path = temp_dir.join(format!( + "test_missing_slot_0_{}.sanctum", + std::process::id() + )); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE meta ( + slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL + );", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + b"SANCTUM\0", + &salt[..], + ¶ms_json, + &wrapped_dek[..], + &nonce[..], + &tag[..] + ], + ) + .unwrap(); + } + let db = Database::open(&db_path).unwrap(); + let res = db.read_slots(); + assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden"); + let err_msg = res.err().unwrap().to_string(); + assert!( + err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"), + "Fehlermeldung: {err_msg}" + ); + let _ = std::fs::remove_file(&db_path); + } +} + +#[test] +fn test_container_meta_authenticate_defense_in_depth() { + use sanctum::storage::{ContainerMeta, SlotMeta}; + + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + let valid_slot_0 = SlotMeta { + slot_id: 0, + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + }; + + let valid_slot_1 = SlotMeta { + slot_id: 1, + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + }; + + // A. Leere Slots -> sofort None + let meta_empty = ContainerMeta { + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + slots: vec![], + }; + assert!(meta_empty.authenticate("TestPassword2026!").is_none()); + + // B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!) + let meta_3_slots = ContainerMeta { + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + slots: vec![ + valid_slot_0.clone(), + valid_slot_1.clone(), + valid_slot_0.clone(), + ], + }; + assert!(meta_3_slots.authenticate("TestPassword2026!").is_none()); + + // C. Fehlender Slot 0 (nur Slot 1) -> sofort None + let meta_no_slot0 = ContainerMeta { + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + slots: vec![valid_slot_1.clone()], + }; + assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none()); + + // D. Ungültige Slot-ID (> 1) -> sofort None + let mut invalid_slot = valid_slot_0.clone(); + invalid_slot.slot_id = 99; + let meta_invalid_id = ContainerMeta { + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + slots: vec![valid_slot_0.clone(), invalid_slot], + }; + assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none()); + + // E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung + let meta_valid_1 = ContainerMeta { + version: 2, + kdf_salt: salt, + kdf_params: kdf_params.clone(), + wrapped_dek: wrapped_dek.clone(), + header_nonce: nonce, + header_tag: tag, + slots: vec![valid_slot_0.clone()], + }; + assert!(meta_valid_1.authenticate("TestPassword2026!").is_some()); +} diff --git a/tests/upgrade_security_test.rs b/tests/upgrade_security_test.rs index 0a9f12a..8f8b8f0 100644 --- a/tests/upgrade_security_test.rs +++ b/tests/upgrade_security_test.rs @@ -1,4 +1,4 @@ -use sanctum::upgrade::{ +use sanctum::upgrade::{ run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY, }; @@ -12,7 +12,11 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\ 0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n"; let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY); - assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err()); + assert!( + res.is_ok(), + "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", + res.err() + ); } #[test] @@ -25,7 +29,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\ 0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n"; let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY); - assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen"); + assert!( + res.is_err(), + "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen" + ); assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN")); } @@ -41,7 +48,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\ // Ein anderer, unautorisierter Public Key let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7"; let res = verify_minisign_signature(payload, sig_text, other_key); - assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen"); + assert!( + res.is_err(), + "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen" + ); } #[test] @@ -55,7 +65,10 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() { }; let res = run_upgrade(&options); - assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden"); + assert!( + res.is_err(), + "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden" + ); let err_msg = res.unwrap_err().to_string(); assert!( err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"), diff --git a/tests/upgrade_test.rs b/tests/upgrade_test.rs index 53b8f0d..fe15c7c 100644 --- a/tests/upgrade_test.rs +++ b/tests/upgrade_test.rs @@ -150,7 +150,8 @@ fn test_json_deserialization_of_gitea_release() { ] }"##; - let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich"); + let release: GiteaRelease = + serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich"); assert_eq!(release.id, 27); assert_eq!(release.tag_name, "v0.5.0"); assert_eq!(release.assets.len(), 3);