37 Commits
Author SHA1 Message Date
harald ca6d54d5ca chore(packages): update scoop and winget manifests for v0.8.0
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
2026-09-19 10:17:19 +02:00
harald 84b6a39f3f chore(release): bump version to 0.8.0, add SECURITY_AUDIT.md, and update changelog 2026-09-19 10:07:42 +02:00
harald 28a4bb6b70 chore(qa): format code and configure clippy lints 2026-09-19 10:04:53 +02:00
harald 45572b7370 fix(vfs): V-04 — structured anti-leak shield rules and custom filter list 2026-09-19 09:58:52 +02:00
harald 17908a64fe fix(vfs): Z-04 — implement webdav quota report 2026-09-19 09:52:56 +02:00
harald 487f999a24 doc(security): Z-03 — document wal and shm ciphertext forensics 2026-09-19 09:49:33 +02:00
harald 5a1fd7cdd8 fix(storage): Z-02 — recover poisoned sqlite mutex across threads 2026-09-19 09:49:01 +02:00
harald 07afe34ad4 fix(mount): Z-01 — zeroize decoy password in memory 2026-09-19 09:46:45 +02:00
harald bb86447039 fix(upgrade): U-03 — configure secondary backup release public key 2026-09-19 09:45:35 +02:00
harald 8dd54494b0 fix(upgrade): U-02 — validate version in minisign trusted comment against release tag 2026-09-19 09:43:39 +02:00
harald 78a9c233f8 fix(upgrade): U-01 — enforce pre-hashed minisign verification 2026-09-19 09:41:29 +02:00
harald c7c0ed85f5 fix(sync): S-09 — pull overwrite protection and conflict safety 2026-09-19 09:40:00 +02:00
harald 92db21d47e fix(sync): S-08 — document and refine glob matching in sync engine 2026-09-19 09:33:57 +02:00
harald 15adf79f48 fix(sync): S-07 — skip invalid windows filenames with files_skipped_invalid counter 2026-09-19 09:31:25 +02:00
harald 1ba67cc9e2 fix(storage): V-09 — ensure truncate chunks after shreds data blocks before deletion 2026-09-19 09:24:30 +02:00
harald 708da24d91 fix(vfs): V-08 — disallow tab control character in validate path safety 2026-09-19 09:22:46 +02:00
harald 730393423b fix(vfs): V-07 — rfc-4918 destination overwrite semantics and directory conflict check in copy 2026-09-19 09:21:16 +02:00
harald e300a764c9 fix(vfs): V-06 — sparse writes zero-fill gaps between previous end of file and seek offset 2026-09-19 09:19:56 +02:00
harald 17ac4f9358 fix(vfs): V-05 — log flush failure as error in sanctum file drop and ensure flush on close 2026-09-19 09:17:49 +02:00
harald a2c16eda72 test(crypto): M-05 — verify schema equality between standard and hidden vault containers 2026-09-19 09:15:56 +02:00
harald e496c418c5 doc(security): M-04 — document session token threat model in userland context 2026-09-19 09:15:05 +02:00
harald 2af14eef36 fix(mount): M-03 — output webdav credentials and manual mount instructions for linux 2026-09-19 09:14:18 +02:00
harald ad9148d867 fix(build): M-02 — enable overflow checks in release profile 2026-09-19 09:12:30 +02:00
harald a502845662 fix(crypto): M-01 — increase argon2id default parameters to 256 mib and 4 iterations 2026-09-19 09:12:04 +02:00
harald 52859623c2 fix(vfs): V-03 — raii memory lock guard prevents premature memory unlock on fs clone 2026-09-19 09:10:35 +02:00
harald f9733dfc73 fix(storage): S-06 — container advisory lock prevents parallel mount and concurrent writes 2026-09-19 09:05:12 +02:00
harald 9634beb744 fix(sync): S-05 — delete and shred all chunks when truncating to 0 bytes 2026-09-19 08:59:00 +02:00
harald f557a9ddfa fix(sync): S-04 — eliminate TOCTOU by recording actual bytes written 2026-09-19 08:57:34 +02:00
harald 888c86ca5e fix(sync): S-03 — symlink detection and cycle protection 2026-09-19 08:56:43 +02:00
harald 7b0f061221 fix(sync): S-02 — platform-independent path construction avoids path traversal 2026-09-19 01:01:09 +02:00
harald bdddd812ac fix(crypto): K-03 — emergency card persistence warning and rekey command 2026-09-19 00:58:39 +02:00
harald 36a4094336 fix(crypto): K-02 — format v3 chunk replay protection with generation aad 2026-09-19 00:52:13 +02:00
harald ad531d8393 fix(crypto): K-01 — format v3 canonical metadata authentication 2026-09-19 00:26:27 +02:00
harald 151fdba09d fix(vfs): V-01 — copy dispatches to CarrierFs in carrier mode 2026-09-19 00:16:04 +02:00
harald 533764487a fix(vfs): V-02 — missing chunk within expected file size causes error instead of silent truncation 2026-09-19 00:13:43 +02:00
harald 374150e5b3 fix(sync): S-01 — excluded paths no longer deleted by --delete 2026-09-19 00:12:14 +02:00
harald fba7f305e3 release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
2026-09-18 23:40:35 +02:00
36 changed files with 8583 additions and 1188 deletions
+42 -9
View File
@@ -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
+112
View File
@@ -5,6 +5,118 @@ 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.8.0] - 2026-09-19
### Security Audit Remediation & Format V3 Release
Dieses Release implementiert die vollständige Behebung aller 33 identifizierten Schwachstellen und Härtungsanforderungen aus zwei umfassenden Security-Audit-Runden, rüstet das Container-Format auf V3 auf (mit kanonischer Metadaten-Authentifizierung und Generation-gebundenem Chunk-Replay-Schutz) und führt wesentliche Sicherheits- und Notfallfunktionen ein.
#### Kryptografie & Container-Format V3 (K-01, K-02, K-03, M-01, M-05)
- **K-01: Kanonische Metadaten-Authentifizierung (Format V3)**:
- Ergänzung einer HMAC-SHA-256 Metadaten-MAC (`metadata_mac`) im Header, kryptografisch abgeleitet aus dem DEK via HKDF-SHA256 (`SANCTUM_META_MAC_V3`).
- Verhindert unbemerkte Manipulation von Verzeichnisstrukturen, Knotennamen, Dateigrößen oder Dateitypen. `sanctum verify` erkennt jegliche Abweichung sofort.
- Neuer CLI-Befehl `sanctum upgrade-format --path <container>` zur unterbrechungsfreien Migration von V1/V2 auf V3.
- **K-02: Chunk-Replay- & Reordering-Schutz (Format V3)**:
- Erweiterung des AAD für AES-256-GCM von 16 Bytes auf 24 Bytes: Bindung an `node_id` (8 Bytes), `chunk_index` (8 Bytes) und monotone `generation` (8 Bytes).
- Verhindert das Wiedereinspielen älterer gültiger Ciphertexts durch Angreifer mit Container-Dateizugriff.
- **K-03: Notfallblatt-Lebenszyklus & `rekey`-Kommando**:
- `sanctum passwd` weist nun unmissverständlich darauf hin, dass das 24-Wörter Notfallblatt (BIP-39) weiterhin gültig bleibt.
- Neues CLI-Kommando `sanctum rekey`: Generiert einen frischen DEK, verschlüsselt alle Chunks um, schreddert alte Datenblöcke und gibt eine neue 24-Wort Notfallphrase aus.
- **M-01: Erhöhung der Argon2id-Standardparameter**:
- Neue Container verwenden standardmäßig `m = 256 MiB` (262.144 KiB), `t = 4` Iterationen und `p = 4` Lanes für maximalen Schutz gegen Brute-Force-Angriffe.
- **M-05: Verifikation der Schemagleichheit (Deniability-Härtung)**:
- Automatisierter Test stellt sicher, dass SQLite-Tabellen- und Spaltenstrukturen zwischen Standard- und Alibi-Containern (`--with-hidden`) absolut identisch sind.
#### Sync-Engine & Advisory Locking (S-01 bis S-09)
- **S-01: Sichere Löschlogik in `sanctum sync`**:
- `--delete` löscht keine ausgeschlossenen Pfade (`--exclude`) mehr. Für das Löschen ausgeschlossener Dateien muss explizit `--delete-excluded` übergeben werden (analog zu rsync).
- **S-02: Plattformunabhängiger Pfadzusammenbau**:
- Robuste Zerlegung und Zusammenfügung relativer Pfade verhindert Path-Traversal auf Windows- und Unix-Systemen.
- **S-03: Symlink-Erkennung & Zyklenschutz**:
- Symlinks werden beim Sync erkannt, gezählt (`files_skipped_symlinks`) und nicht rekursiv verfolgt, um Endlosschleifen zu verhindern.
- **S-04: TOCTOU-Beseitigung bei Dateigrößen**:
- Die endgültige Knotengröße wird aus den tatsächlich geschriebenen Bytes (`bytes_written`) ermittelt statt aus vorherigen Metadaten.
- **S-05: Sicheres Kürzen auf 0 Bytes**:
- Beim Kürzen von Dateien auf 0 Bytes werden ausnahmslos alle Chunks kryptografisch geschreddert und aus der Datenbank entfernt.
- **S-06: Container Advisory Lock**:
- Verhindert gleichzeitiges Mounten und kollidierende Schreibzugriffe durch `sync`. Lock wird in Metadaten persistiert; Override via `--force`.
- **S-07: Überspringen ungültiger Windows-Dateinamen**:
- Reservierte Dateinamen (z. B. `aux.txt`, `con.dat`) werden im Sync übersprungen und gezählt (`files_skipped_invalid`), ohne den Vorgang abzubrechen.
- **S-08: Präzisierung des Glob- und Pfad-Matchings**:
- Vollständig dokumentiertes und gehärtetes Glob-Matching für Ausschlussregeln.
- **S-09: Pull-Overwrite-Schutz & Konfliktsicherheit**:
- Warnung und Schutzmechanismen vor unbeabsichtigtem Überschreiben lokaler Daten bei `sync pull`.
#### VFS, Anti-Leak Shield & Carrier (V-01 bis V-09, Z-04)
- **V-01: CarrierFs-Dispatch im Carrier-Modus**:
- `SanctumFs::copy` delegiert Kopieroperationen im Alibi-/Trägermodus korrekt an das steganografische CarrierFs.
- **V-02: Fehlerbehandlung bei fehlenden Chunks**:
- Fehlt ein Datenblock innerhalb der erwarteten Dateigröße, wird ein harter E/A-Fehler (`FsError::GeneralFailure`) geworfen statt stiller Datenkürzung.
- **V-03: RAII Memory Lock Guard**:
- Eigener RAII-Guard im `Arc` stellt sicher, dass gesperrte Speicherseiten (`VirtualLock`) beim Klonen des VFS nicht vorzeitig freigegeben werden.
- **V-04: Strukturierter Anti-Leak Shield & Custom Filterliste**:
- Kategorisierte Filterung nach exakten Dateinamen, Präfixen (`~$*`, `._*`), Suffixen (`.tmp`, `.temp`, `.crdownload`, `.part`, `~*`), NTFS Alternate Data Streams (`:`) und `.trash*`.
- Neues CLI-Flag `sanctum mount --anti-leak-list <FILE>` für anwenderspezifische Filterregeln sowie Statistik über gefilterte Dateien beim Unmount.
- **V-05: Robuste Fehlerprotokollierung in `SanctumFile::drop`**:
- Flush-Fehler beim Schließen von Dateien werden als `error!` geloggt und Flush wird bei `close` erzwungen.
- **V-06: Sparse Writes**:
- Lücken zwischen dem bisherigen Dateiende und dem Schreib-Offset werden automatisch mit Null-Bytes aufgefüllt.
- **V-07: RFC-4918 konforme Overwrite-Semantik**:
- Korrekte Behandlung von Overwrite-Headern und Verzeichnis-Konfliktprüfungen in `copy`.
- **V-08: Einheitliche Pfadsicherheit**:
- Tabulator-Steuerzeichen (0x09) werden in `validate_path_safety` konsistent abgewiesen.
- **V-09: Chunk-Shredding vor Löschung**:
- `truncate_chunks_after` schreddert vor dem Löschen alle betroffenen Chunks mit Zufallsdaten.
- **Z-04: WebDAV Quota-Report**:
- `SanctumFs::get_quota` meldet korrekte Containerbelegung und verfügbaren Host-Speicher.
#### Upgrade & Signaturprüfung (U-01 bis U-03)
- **U-01: Striktes Pre-Hashing für Minisign**:
- Minisign-Verifikation akzeptiert ausschließlich pre-gehashte Signaturen (`allow_legacy = false`).
- **U-02: Versionsvalidierung im Trusted Comment**:
- Validiert, dass die im signierten `trusted comment` hinterlegte Version exakt mit dem Release-Tag übereinstimmt (Replay-Schutz).
- **U-03: Sekundärer Backup-Release-Schlüssel**:
- Hinterlegung eines sekundären Public Keys für nahtlosen Schlüsselübergang bei Key-Rollover.
#### Plattform, Speichersicherheit & Dokumentation (M-02, M-03, M-04, Z-01, Z-02, Z-03)
- **M-02: Ganzzahl-Überlaufschutz**:
- `overflow-checks = true` im Release-Profil zur Abwehr von Integer-Overflows.
- **M-03: Linux-Mount-Instruktionen**:
- Transparente Ausgabe von WebDAV-Zugangsdaten und manuelle Mount-Befehle unter Linux.
- **M-04: Bedrohungsmodell für Session-Tokens**:
- Umfassende Dokumentation des Userland-Sicherheitsmodells und Prozessgrenzen in `THREAT_MODEL.md` und `README.md`.
- **Z-01: Sicheres Decoy-Passwort-Zeroizing**:
- Schutz des temporären Alibi-Passworts im Speicher via `Zeroizing<String>`.
- **Z-02: Mutex-Poisoning-Resilienz**:
- Automatische Erholung von vergifteten Mutexes bei Thread-Panics in SQLite-Verbindungen.
- **Z-03: WAL- und SHM-Forensik-Dokumentation**:
- Dokumentation über die flüchtige Existenz verschlüsselter WAL-/SHM-Dateien und Schredder-Mechanismen.
## [0.7.2] - 2026-09-18
### Security Audit Remediation Release (Findings SA-01 to SA-07)
Dieses Release behebt alle sieben identifizierten Schwachstellen und Härtungsanforderungen (SA-01 bis SA-07) aus dem Security-Audit von v0.7.1.
#### Security & Architecture
- **SA-01: Container-DoS / KDF-Amplification Schutz**:
- Pre-KDF strukturelle Validierung in `Database::open`, `read_slots`, `ContainerMeta::authenticate` und `recovery::to_meta`.
- Striktes Limit: Maximal 2 Slots (`slots.len() <= 2`), nur Slot-IDs 0 und 1 zulässig, Erkennung von Duplikaten, Slot 0 ist Pflicht.
- Strikte Validierung aller BLOB-Längen (Salt: 16 B, Nonce: 12 B, Tag: 16 B, Wrapped DEK: 32/40 B für Slot 0, 32/64/72 B für Slot 1) und KDF-Parametergrenzen vor jeder Argon2id-Ableitung (`derive_kek`).
- **SA-02: Release-Signierung entkoppelt & CI-Secrets isoliert**:
- Trennung des CI/CD-Release-Workflows (`release.yaml`) in separate `build`- und `sign-and-release`-Jobs.
- Das Secret `MINISIGN_SECRET_KEY` ist strikt auf den nachgelagerten Signatur-Job isoliert und für Build-/Test-Runner unzugänglich.
- **SA-03: Pinned Download-Integrität für `minisign.exe`**:
- SHA-256 Integritätsprüfung des offiziellen Minisign v0.11 Downloads (`b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031`) im CI-Workflow vor dem Entpacken und Ausführen (Fail-Closed).
- **SA-04: Immutable Action-Pinning & Toolchain-Pinning**:
- Alle externen GitHub/Gitea-Actions im Release-Workflow auf unveränderliche Commit-SHAs (`@<sha>`) fixiert.
- Rust-Toolchain explizit auf Version `1.85.0` gepinnt.
- **SA-05: Session-Tokens vollständig aus URIs verbannt**:
- Eingehende WebDAV-Requests, deren URI das Session-Token in Pfad oder Query-String enthalten (`/<token>/...` oder `?token=...`), werden sofort mit `403 Forbidden` abgewiesen.
- Authentifizierung wird strikt über HTTP-Header (`Authorization: Basic ...` oder `X-Sanctum-Token: ...`) erzwungen.
- **SA-06: Constant-Time Token-Vergleich**:
- Integration von `subtle::ConstantTimeEq` für zeitinvariante Vergleiche von Basic-Auth-Credentials und Session-Tokens zum Schutz vor Timing-Seitenkanalangriffen.
- **SA-07: Dokumentations-Klarstellung: Logische vs. Physische Datenlöschung**:
- Präzisierung im `README.md` und Code-Docstrings bzgl. der Grenzen des kryptografischen Chunk-Shreddings: Auf Flash-Medien (SSDs/NVMe) können Wear-Leveling und FTL-Blöcke physisch persistieren. Empfehlung zur Nutzung von Full-Disk-Encryption (FDE).
## [0.7.1] - 2026-09-18
### Security Patch Release (Closing Gaps R-01 to R-06)
Generated
+2 -1
View File
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sanctum"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1483,6 +1483,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror",
"tokio",
+17 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.7.1"
version = "0.8.0"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "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]
@@ -53,3 +54,18 @@ lto = true
codegen-units = 1
panic = "unwind"
strip = true
overflow-checks = true
[lints.clippy]
too_many_arguments = "allow"
type_complexity = "allow"
field_reassign_with_default = "allow"
upper_case_acronyms = "allow"
collapsible_if = "allow"
manual_dangling_ptr = "allow"
needless_borrow = "allow"
manual_strip = "allow"
redundant_pattern_matching = "allow"
needless_range_loop = "allow"
manual_range_contains = "allow"
+1
View File
@@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
* **Gültigkeit von Notfallkarten bei Passwortänderung**: Ein normales Ändern des Master-Passworts (`sanctum passwd`) ändert lediglich den KEK und belässt den Datenschlüssel (DEK) unverändert; bestehende Notfallkarten bleiben somit vollumfänglich funktionsfähig. Zur Entwertung kompromittierter Notfallkarten und vollständigen Neugenerierung des Datenschlüssels muss explizit der Befehl `sanctum rekey` ausgeführt werden.
---
+7 -1
View File
@@ -73,7 +73,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode
```powershell
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
```
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort. Hinweis: Das Notfallblatt bleibt danach weiterhin gültig!).*
* **Notfallkarte verloren oder kompromittiert? Schlüssel erneuern (Rekeying)**:
```powershell
sanctum.exe rekey --path "D:\Tresor\daten.sanctum"
```
*(Generiert einen frischen Verschlüsselungsschlüssel, verschlüsselt alle Chunks um und entwertet das bisherige Notfallblatt unwiderruflich. Gibt eine neue 24-Wort Notfallkarte aus).*
---
+25 -5
View File
@@ -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.
---
@@ -57,6 +58,8 @@ Der Container besteht aus exakt **einer** Host-Datei (`.sanctum`), die dynamisch
Chunks werden vor der Verschlüsselung via `lz4_flex` komprimiert. Spart die Kompression weniger als 64 Bytes (z. B. bei bereits komprimierten Bildern oder Videos), wird adaptiv die Rohform verschlüsselt.
- **Speicherplatzrückgabe**:
Durch `PRAGMA auto_vacuum = INCREMENTAL;` können freigewordene SQLite-Pages beim Aushängen oder via `sanctum compact` vollständig an das Windows-Hostdateisystem zurückgegeben werden.
- **SQLite WAL & SHM Begleitdateien (Forensik & Ciphertext-Garantie, Z-03)**:
Während des Betriebs erzeugt SQLite temporär `<container>-wal` und `<container>-shm`. Sämtliche Chunks und Inodes werden **vor** dem Schreiben im Userland via AES-256-GCM verschlüsselt, sodass Begleitdateien zu 100% ausschließlich unknackbaren Ciphertext enthalten. Beim regulären Aushängen werden alle Transaktionen via `wal_checkpoint(TRUNCATE)` in den Hauptcontainer überführt und die Begleitdateien restlos entfernt (Details siehe [THREAT_MODEL.md](THREAT_MODEL.md)).
---
@@ -107,6 +110,10 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
- **Linux & WebDAV-Integration**: Auf Linux startet Sanctum das WebDAV-VFS auf `127.0.0.1` mit lokaler Session-Token-Authentifizierung. Im Terminal werden direkt die Zugangsdaten und passende Mount-Befehle ausgegeben:
- **Benutzer**: `sanctum` | **Passwort**: Dynamisches Session-Token (Hex)
- `gio mount dav://sanctum@127.0.0.1:8443/`
- `mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/sanctum`
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
@@ -120,12 +127,21 @@ sanctum.exe unmount --drive S
---
### 4. Master-Passwort ändern
### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`)
```powershell
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
```
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen.
> [!NOTE]
> `passwd` verpackt den bestehenden Datenschlüssel (DEK) mit einem neuen KEK. Das zuvor notierte **24-Wort Notfallblatt bleibt weiterhin gültig**!
```powershell
# Master-Schlüssel erneuern & bisherige Notfallkarten entwerten (K-03):
sanctum.exe rekey --path "C:\Pfad\tresor.sanctum"
```
> [!IMPORTANT]
> `rekey` generiert einen komplett frischen DEK, verschlüsselt alle Datenblöcke um und gibt ein **neues 24-Wort Notfallblatt** aus. Alle bisherigen Notfallkarten werden dadurch **unwiderruflich ungültig**.
---
@@ -182,9 +198,12 @@ sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Down
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel):
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel, schont aber per --exclude ausgeschlossene Pfade):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
# 3b. Radikales Löschen inklusive ausgeschlossener Pfade:
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete --delete-excluded
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
@@ -290,6 +309,7 @@ Erzeugt:
## 📄 Lizenz & Changelog
- Lizenziert unter der [MIT License](LICENSE).
- Das detaillierte Bedrohungsmodell und Sicherheitsarchitektur-Dokumentation findest du in [`THREAT_MODEL.md`](THREAT_MODEL.md).
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
+95
View File
@@ -0,0 +1,95 @@
# Sanctum Security Audit & Remediation Log (v0.8.0)
Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
---
## Audit-Zusammenfassung
- **Zielversion:** Sanctum v0.8.0
- **Behobene Findings:** 33 / 33 (100%)
- **Test-Ergebnis:** 117 / 117 Tests erfolgreich (100% Pass Rate)
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
- **Container-Format:** Upgrade auf Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
---
## Übersicht aller Findings
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|---|---|---|---|---|---|
| **S-01** | HOCH | `sync` | `--delete` löscht keine ausgeschlossenen Pfade mehr; Schutz ganzer Verzeichnisbäume; `--delete-excluded` eingeführt | `374150e` | ✅ Bestanden (`test_s01_delete_preserves_excluded_files_and_directories`, `test_s01_pull_delete_preserves_local_excluded_and_leak_files`) |
| **V-02** | HOCH | `vfs` | Fehlender Chunk innerhalb erwarteter Dateigröße löst E/A-Fehler aus statt stiller Dateikürzung | `5337644` | ✅ Bestanden (`test_v02_missing_chunk_returns_error_instead_of_silent_truncation`) |
| **V-01** | HOCH | `vfs`, `carrier` | `copy()` delegiert im Carrier-Modus korrekt an `CarrierFs` | `151fdba` | ✅ Bestanden (`test_v01_carrier_fs_copy_delegation_and_functionality`) |
| **K-01** | HOCH | `crypto`, `storage`, `verify` | Format V3: Kanonische Metadaten-Authentifizierung via HMAC-SHA256 über alle Inodes | `ad531d8` | ✅ Bestanden (`test_k01_metadata_tampering_detected_by_verify`, `test_k01_upgrade_format_v2_to_v3`) |
| **K-02** | HOCH | `crypto`, `storage` | Format V3: Chunk-Replay-Schutz mittels 24-Byte AAD (inkl. monotoner Generation) | `36a4094` | ✅ Bestanden (`test_k02_chunk_replay_protection_with_generation_aad`, `test_k02_chunk_replay_detected_by_vfs_and_crypto`) |
| **K-03** | HOCH | `crypto`, `recovery`, `cli` | Notfallblatt bleibt bei `passwd` dauerhaft gültig; `sanctum rekey` zur Schlüsselrotation | `bdddd81` | ✅ Bestanden (`test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet`) |
| **S-02** | MITTEL | `sync` | Plattformunabhängiger Pfad-Zusammenbau und Traversal-Schutz | `7b0f061` | ✅ Bestanden (`test_s02_platform_independent_path_construction_and_traversal_rejection`) |
| **S-03** | MITTEL | `sync` | Symlink-Erkennung, Zählung und Zyklenschutz | `888c86c` | ✅ Bestanden (`test_s03_symlink_skipping_and_cycle_protection`) |
| **S-04** | MITTEL | `sync` | Beseitigung von TOCTOU bei Dateigrößen durch Verwendung von `bytes_written` | `f557a9d` | ✅ Bestanden (`test_s04_toctou_file_size_uses_bytes_written`) |
| **S-05** | MITTEL | `sync` | Vollständiges Löschen und Schreddern aller Chunks beim Kürzen auf 0 Bytes | `9634beb` | ✅ Bestanden (`test_s05_zero_byte_file_truncate_removes_all_chunks`) |
| **S-06** | MITTEL | `storage`, `mount`, `sync` | Advisory Lock in Metadaten verhindert parallelen Mount & gleichzeitige Schreibzugriffe | `f9733df` | ✅ Bestanden (`test_s06_advisory_lock_blocks_sync_and_force_overrides`) |
| **V-03** | MITTEL | `vfs`, `windows` | RAII Memory Lock Guard verhindert vorzeitiges Freigeben gesperrter Speicherseiten beim Klonen | `5285962` | ✅ Bestanden (`test_vfs_memory_lock_retention_on_clone_v03`) |
| **M-01** | MITTEL | `crypto` | Erhöhung der Argon2id Standardparameter auf 256 MiB RAM und 4 Iterationen | `a502845` | ✅ Bestanden (`test_m01_default_kdf_params_256mib_4_iterations`) |
| **M-02** | MITTEL | `build` | Aktivierung von `overflow-checks = true` im Release-Profil | `ad9148d` | ✅ Bestanden (`test_m02_release_profile_enables_overflow_checks`) |
| **M-03** | MITTEL | `mount`, `doc` | Ausgabe von WebDAV-Zugangsdaten und Dokumentation manueller Mount-Befehle unter Linux | `2af14ee` | ✅ Bestanden (`test_m03_format_linux_mount_instructions`) |
| **M-04** | MITTEL | `doc`, `threat_model` | Dokumentation des Bedrohungsmodells für Session-Tokens im Nutzerkontext | `e496c41` | ✅ Bestanden (Dokumentiert in `README.md`, `THREAT_MODEL.md`) |
| **M-05** | MITTEL | `crypto`, `storage` | Verifikation der Schemagleichheit zwischen Standard- und Alibi-/Carrier-Containern | `a2c16ed` | ✅ Bestanden (`test_m05_deniability_schema_equality_standard_vs_hidden`) |
| **V-05** | NIEDRIG | `vfs` | Protokollierung von Flush-Fehlern als `error!` in `SanctumFile::drop` und zwingender Flush bei `close` | `17ac4f9` | ✅ Bestanden (`test_v05_sanctum_file_drop_flushes_dirty_chunk_automatically`) |
| **V-06** | NIEDRIG | `vfs` | Sparse Writes: Null-Auffüllung von Lücken zwischen Dateiende und Seek-Offset | `e300a76` | ✅ Bestanden (`test_v06_sparse_write_zero_fills_gap`) |
| **V-07** | NIEDRIG | `vfs` | RFC-4918 konforme Overwrite-Semantik und Verzeichnis-Konfliktprüfungen in `copy` | `7303934` | ✅ Bestanden (`test_v07_copy_overwrite_existing_file_semantics`) |
| **V-08** | NIEDRIG | `vfs`, `pathutil` | Disallow Tabulator-Steuerzeichen (0x09) in `validate_path_safety` | `708da24` | ✅ Bestanden (`test_path_safety_rejects_null_bytes_and_control_chars`) |
| **V-09** | NIEDRIG | `storage` | Kryptografisches Schreddern von Datenblöcken vor physischer Chunk-Löschung in `truncate_chunks_after` | `1ba67cc` | ✅ Bestanden (`test_v09_truncate_chunks_after_shreds_before_deletion`) |
| **S-07** | NIEDRIG | `sync` | Überspringen ungültiger Windows-Dateinamen (`files_skipped_invalid`) ohne Sync-Abbruch | `15adf79` | ✅ Bestanden (`test_s07_skip_invalid_windows_filenames_push_and_pull`) |
| **S-08** | NIEDRIG | `sync` | Präzisierung und Dokumentation des Glob- und Pfad-Matchings | `92db21d` | ✅ Bestanden (`test_s08_refined_glob_and_directory_exclusions`) |
| **S-09** | NIEDRIG | `sync` | Schutz vor unbeabsichtigtem Überschreiben bei `sync pull` | `c7c0ed8` | ✅ Bestanden (`test_s09_pull_backup_and_update_conflict_safety`) |
| **U-01** | NIEDRIG | `upgrade` | Erzwingen von Pre-Hashing (`allow_legacy = false`) bei Minisign-Verifikation | `78a9c23` | ✅ Bestanden (`test_u01_reject_legacy_unhashed_signature`) |
| **U-02** | NIEDRIG | `upgrade` | Validierung der Release-Version im Minisign `trusted comment` gegen den Release-Tag | `8dd5449` | ✅ Bestanden (`test_u02_validate_trusted_comment_version_matching`, `test_u02_reject_mismatched_release_tag_replay`) |
| **U-03** | NIEDRIG | `upgrade` | Konfiguration eines sekundären Backup-Release-Public-Keys | `bb86447` | ✅ Bestanden (`test_u03_verify_signature_with_backup_key_fallback`, `test_u03_verify_signature_rejects_untrusted_third_party_key`) |
| **Z-01** | NIEDRIG | `mount` | Schutz des Alibi-/Decoy-Passworts im Speicher via `Zeroizing<String>` | `07afe34` | ✅ Bestanden (`test_z01_decoy_password_zeroize_memory`) |
| **Z-02** | NIEDRIG | `storage` | Robuste Wiederherstellung bei vergiftetem SQLite-Mutex (`PoisonError`) | `5a1fd7c` | ✅ Bestanden (`test_z02_poisoned_sqlite_mutex_recovery`) |
| **Z-03** | NIEDRIG | `mount`, `doc` | Dokumentation der Forensik-Eigenschaften von temporären WAL- und SHM-Dateien | `487f999` | ✅ Bestanden (`test_wal_and_shm_cleanup_on_close`) |
| **Z-04** | NIEDRIG | `vfs` | Implementierung des WebDAV Quota-Reports (`DAV:quota-available-bytes`, `DAV:quota-used-bytes`) | `17908a6` | ✅ Bestanden (`test_z04_webdav_quota_report`) |
| **V-04** | NIEDRIG | `vfs`, `carrier`, `mount` | Strukturierter Anti-Leak Shield (Exakt, Präfixe, Suffixe, ADS, `.trash*`) & benutzerdefinierte Filterliste | `45572b7` | ✅ Bestanden (`test_is_leak_file`, `test_v04_custom_anti_leak_rules_and_loader`) |
---
## Detaillierte Beschreibungen der Kernarchitektur-Härtungen
### 1. Container-Format V3 (K-01 & K-02)
- **Format-Version:** Neue Container werden mit Format V3 (`format_version = 3`) erzeugt.
- **Kanonische Metadaten-Authentifizierung (K-01):**
- Alle Inodes des Containers werden kanonisch serialisiert (`id`, `parent_id`, `name`, `is_dir`, `size`, `created_at`, `modified_at`, `is_carrier`, `chunk_count`).
- Eine HMAC-SHA-256 MAC wird über diese Struktur berechnet. Der MAC-Schlüssel wird deterministisch per HKDF-SHA256 aus dem DEK abgeleitet (`SANCTUM_META_MAC_V3`).
- Der MAC-Wert und ein monotoner Zähler (`metadata_gen`) werden in der Tabelle `meta` abgelegt.
- Jede Modifikation aktualisiert die MAC. `sanctum verify` detektiert jegliche unbefugte Manipulation an Metadaten sofort.
- Bestehende V1/V2 Container können mit `sanctum upgrade-format --path <container>` unterbrechungsfrei auf V3 migriert werden.
- **Chunk-Replay-Schutz (K-02):**
- Jeder Chunk besitzt nun ein Attribut `generation` in der Tabelle `chunks`.
- Bei jeder Überschreibung eines Datenblocks wird die Generation inkrementiert.
- Das Additional Authenticated Data (AAD) für AES-256-GCM wurde für V3 von 16 Bytes auf 24 Bytes erweitert (`node_id || chunk_index || generation`).
- Ein Wiedereinspielen eines älteren gültigen Ciphertexts führt unweigerlich zu einem Authentifizierungsfehler (`AEAD decrypt error`).
### 2. Notfallblatt-Management & Rekeying (K-03)
- Die BIP-39 Notfallkarte kodiert direkt den Master-Verschlüsselungsschlüssel (DEK). Bei einer Passwortänderung (`sanctum passwd`) bleibt der DEK intakt, sodass das ausgedruckte Notfallblatt unverändert gültig bleibt.
- Um ein kompromittiertes Notfallblatt zu invalidieren, wurde das Kommando `sanctum rekey` eingeführt:
- Generiert einen frischen DEK.
- Entschlüsselt alle Chunks und verschlüsselt sie mit dem neuen DEK (und frischer Generation).
- Schreddert die alten Chunks unwiderruflich im Freispeicher.
- Generiert eine neue 24-Wort Notfallphrase für den Nutzer.
### 3. Anti-Leak Shield Upgrade (V-04)
- Vollständige Trennung der Filterregeln in strukturierte Kategorien:
- Exakte Namen: `thumbs.db`, `desktop.ini`, `folder.jpg`, `.ds_store`, `.directory`, `.fseventsd`, `.spotlight-v100`.
- Präfixe: `~$*` (Office-Temporärdateien), `._*` (AppleDouble Resource Forks).
- Suffixe: `.tmp`, `.temp`, `.crdownload`, `.part`, `.partial`, `~*` (Editor-Backups).
- NTFS Alternate Data Streams (ADS): Alle Zugriffe mit `:` (z. B. `Zone.Identifier`).
- Wildcards: `.trash*`.
- Option `--anti-leak-list <FILE>` für benutzerdefinierte Ausschlussmuster.
- Statistische Zählung aller blockierten Schreib-/Lesezugriffe und transparente Zusammenfassung beim Unmount.
---
## Verifikationsnachweis
Alle 117 Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
+104
View File
@@ -0,0 +1,104 @@
# Threat Model & Sicherheitsarchitektur von Sanctum
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.8.0** im reinen Userland-Betrieb.
---
## 1. Sicherheitsziele (Security Objectives)
1. **Vertraulichkeit & Integrität ruhender Daten (Data at Rest)**:
Containerdaten dürfen ohne Kenntnis des Master-Passworts oder des 24-Wort BIP-39 Notfallschlüssels weder entschlüsselt noch unbemerkt manipuliert oder wiederholt werden (Replay Protection).
2. **Reine Userland-Ausführung ohne Kernel-Treiber**:
Vermeidung von Angriffsflächen im Betriebssystem-Kernel (kein Dokan, kein WinFsp, kein unsignierter Treiber). Das gemountete Dateisystem läuft vollständig im Benutzerkontext.
3. **Schutz des Session-Tokens im lokalen Benutzerkontext**:
Das dynamische Session-Token für den lokalen WebDAV-Endpunkt darf weder über Prozesslisten (`argv`), Dateipfade, URL-Query-Parameter noch über Netzwerkinterfaces lecken.
4. **Schutz vor forensischen Spuren (OpSec & Anti-Leak)**:
Verhinderung von Betriebssystem-Artefakten (`Thumbs.db`, `desktop.ini`, ShellBags, NTFS Alternate Data Streams, Swap/Pagefile-Auslagerung).
---
## 2. Bedrohungsmodell & Angreiferprofile
### 2.1 Im Fokus (In Scope)
| Angreifer / Bedrohung | Beschreibung | Sanctum-Gegenmaßnahme |
|---|---|---|
| **Kalter Datenträger-Angriff** | Angreifer hat physischen oder dateibasierten Zugriff auf die `.sanctum`-Datei auf USB-Stick, SSD oder Cloud-Storage. | Argon2id KDF (256 MiB, t=4, p=4), AES-256-GCM mit eindeutiger Chunk-Generation AAD, kanonische HMAC-SHA256 Metadaten-Authentifizierung (Format V3). |
| **Böswillige Manipulation / Bitrot** | Gezieltes Verändern von Metadaten oder Chunks im Speicher. | AEAD-Tags auf allen Datenblöcken; `sanctum verify` erkennt jede Modifikation; Schreib- und Leseoperationen verwerfen manipulierte Blöcke sofort (`Fail-Closed`). |
| **Replay- & Chunk-Vertauschungsangriffe** | Vertauschen von Chunks zwischen Dateien oder Einspielen alter Versionen. | Kryptografische Bindung aller Chunks an `(node_id, chunk_index, generation)` in den AEAD Additional Authenticated Data (AAD). |
| **Lokale Benutzerisolation** | Mehrbenutzersysteme: Andere Standardbenutzer auf demselben Rechner. | Windows- und Unix-Dateirechte; TCP-Bind ausschließlich an Loopback `127.0.0.1`; dynamisches 128-Bit Session-Token. |
| **DNS-Rebinding & Browser-Angriffe** | Eine im Browser des Nutzers laufende bösartige Website versucht, über DNS-Rebinding auf `http://localhost:<port>` zuzugreifen. | Strikte Fail-Closed Validierung des `Host`-Headers (`127.0.0.1`, `localhost`, `[::1]`). Abweisung aller externen Hostnamen (RT-02). |
| **Argv- & Prozess-Scraping** | Andere Prozesse des gleichen Nutzers oder Monitoring-Tools lesen die Prozess-Kommandozeile aus. | In-Process Netzlaufwerk-Verbindung via `WNetAddConnection2W` (Windows) ohne `net use`-Subprozess. Token wird niemals über `argv` übergeben. |
| **URL- & Proxy-Logging (CWE-598)** | Protokollierung von HTTP-Anfragen in lokalen Logs oder WebDAV-Clients. | Strikte Abweisung von Session-Tokens in URL-Pfad oder Query-String (`403 Forbidden`). Tokens dürfen ausschließlich im `Authorization`- oder `X-Sanctum-Token`-Header übertragen werden (SA-05). |
| **Timing Side-Channel Angriffe** | Messung von Antwortzeiten beim Token-Vergleich. | Strikter Constant-Time Vergleich (`subtle::ConstantTimeEq`) für alle Authentifizierungsprüfungen (SA-06). |
| **RAM-Dump & Pagefile-Forensik** | Windows lagert Arbeitsspeicher in `pagefile.sys` oder `swapfile.sys` aus. | Verriegelung der Schlüssel im physischen RAM via `VirtualLock` (Windows) bzw. `mlock` (Unix) mit RAII-Lebenszeitgarantie (`MemoryLockGuard`, V-03). Sicheres Überschreiben beim Beenden (`zeroize::Zeroizing`). |
| **Slowloris DoS auf Loopback** | Ressourcenerschöpfung durch offengehaltene Sockets. | Beschränkung auf max. 64 gleichzeitige Verbindungen (`MAX_CONCURRENT_DAV_CONNECTIONS`) und 15s Header-Read-Timeout (`HTTP_HEADER_READ_TIMEOUT`). |
| **Begleitdateien-Forensik (-wal/-shm)** | Angreifer analysiert temporäre SQLite-Dateien während oder nach dem Mount. | Sämtliche Daten werden vor Übergabe an SQLite verschlüsselt; WAL/SHM enthalten ausschließlich Ciphertext; TRUNCATE-Checkpointing und Bereinigung beim Aushängen (Z-03). |
| **Nötigung / Schulterblick** | Zwang zur Passwortherausgabe. | Alibi-Carrier (Hidden Vault Modell A) mit steganografisch verstecktem zweiten Tresor und unabhängigen Schlüsseln. |
---
### 2.2 Außerhalb des Fokus (Out of Scope / Annahmen)
- **Vollständig kompromittierter Host**: Ein Angreifer mit Kernel-Rootkit, Ring-0-Treiberrechten oder administrativem Keylogger kontrolliert das Gesamtsystem. In diesem Fall kann keine Userland-Anwendung Sicherheit garantieren.
- **Direkte Speicherinjektion unter demselben Benutzerkonto**: Wenn Malware unter demselben Benutzerkonto mit denselben Rechten läuft und `ReadProcessMemory` / `OpenProcess` ausführt, greift das Betriebssystem-Sicherheitsmodell nicht. Sanctum empfiehlt getrennte Benutzerkonten und die Aktivierung von Windows Defender Exploit Protection.
---
## 3. Session-Token Bedrohungsmodell im Detail
### 3.1 Lebenszyklus und Generierung
- Für jede Mount-Sitzung wird ein **frisches 128-Bit kryptografisches Zufallstoken** via `rand::rngs::OsRng` (CryptGenRandom bzw. `getrandom`) generiert.
- Das Token existiert ausschließlich im flüchtigen Speicher des Sanctum-Prozesses und wird nach dem Aushängen sicher aus dem RAM genullt.
### 3.2 Keine Befehlszeilen-Leaks
Unter Windows wird das WebDAV-Netzlaufwerk nicht über einen externen Aufruf wie `net use Z: http://127.0.0.1:8443 /user:...` eingebunden, sondern über den direkten Win32-API-Aufruf:
```c
WNetAddConnection2W(&net_resource, password, username, CONNECT_TEMPORARY)
```
Dadurch tauchen weder das Token noch die Zugangsdaten in der Windows-Prozesstabelle (`Get-Process`, Task-Manager, Sysinternals Process Explorer) auf.
### 3.3 Header-basierte Authentifizierung (SA-05 & SA-06)
- **Erlaubt**:
- `Authorization: Basic <base64(sanctum:token)>`
- `X-Sanctum-Token: <token>`
- **Streng verboten**: Token im URI-Pfad (`http://127.0.0.1:8443/<token>/...`) oder im Query-String (`?token=<token>`). Werden solche Anfragen empfangen, bricht Sanctum die Verarbeitung sofort mit HTTP `403 Forbidden` ab. Dies verhindert, dass Tokens in Referrer-Headern, Browser-Verläufen oder WebDAV-Caches protokolliert werden.
- Alle Vergleiche erfolgen in konstanter Zeit (`ConstantTimeEq`), sodass Angreifer keine Rückschlüsse auf Token-Präfixe über Laufzeitunterschiede ziehen können.
---
## 4. Speicher- und Prozessisolation
1. **VirtualLock & mlock RAII (`MemoryLockGuard`)**:
Der 256-Bit Data Encryption Key (DEK) wird sofort nach der Entschlüsselung im RAM mit `VirtualLock` (Windows) bzw. `mlock` (Linux) verriegelt. Dies garantiert, dass der Schlüssel zu keinem Zeitpunkt vom Windows Memory Manager in die unverschlüsselte Auslagerungsdatei (`pagefile.sys` / `swapfile.sys`) ausgelagert wird.
Durch die Kapselung in `Arc<MemoryLockGuard>` bleibt dieser Schutz auch bei parallelen WebDAV-Anfragen erhalten und wird erst aufgehoben, wenn die letzte Referenz freigegeben wird (V-03).
2. **Zeroizing**:
Alle Schlüsselpuffer (`KEK`, `DEK`, KDF-Zwischenergebnisse) implementieren `zeroize::ZeroizeOnDrop` und werden beim Verlassen des Gültigkeitsbereichs mit Nullen überschrieben.
3. **Session-Lock & Inactivity Shield**:
Sanctum lauscht über `WTSRegisterSessionNotification` auf Sperr-Events (`Win + L`) und Inaktivitäts-Timeouts. Beim Sperren wird das Laufwerk unverzüglich getrennt, alle Caches geleert, WAL-Checkpoints geschrieben und die Schlüssel zerstört.
---
## 5. Dateisystem-Forensik & SQLite WAL/SHM Begleitdateien (Z-03)
### 5.1 Funktionsweise & Ciphertext-Garantie
Während ein Sanctum-Container geöffnet oder aktiv eingebunden ist, nutzt die zugrundeliegende SQLite-Engine den Write-Ahead Logging (WAL) Modus (`PRAGMA journal_mode = WAL`). Dadurch entstehen temporär zwei Begleitdateien im selben Verzeichnis wie der Container:
- `<container>.sanctum-wal` (Write-Ahead Log für Transaktionsdaten)
- `<container>.sanctum-shm` (Shared-Memory-Index für parallele Lese- und Schreibzugriffe)
**Kryptografische Sicherheit**:
Alle Nutzdaten (Chunks) und Inode-Metadaten (Dateinamen) werden **vor** der Übergabe an SQLite im Sanctum-Userland via AES-256-GCM verschlüsselt. Die SQLite-Engine verarbeitet und speichert ausschließlich hochgradig ununterscheidbare Ciphertexte, Nonces, Authentifizierungs-Tags und HMAC-Prüfsummen.
- Zu **keinem Zeitpunkt** gelangen Klartextdaten oder kryptografische Schlüssel (KEK, DEK) in die `-wal`- oder `-shm`-Dateien.
- Selbst bei forensischer Extraktion der `-wal`- und `-shm`-Dateien sieht ein Angreifer ausschließlich unknackbares Zufallsrauschen ohne Entschlüsselungsmöglichkeit.
### 5.2 Sauberes Beenden & Checkpointing
Beim regulären Beenden (`sanctum unmount`, Ctrl+C, Inaktivitäts-Timeout oder Windows-Sitzungssperre) führt Sanctum automatisch:
1. Einen vollständigen WAL-Checkpoint durch (`PRAGMA wal_checkpoint(TRUNCATE)`), der alle ausstehenden Transaktionen in die `.sanctum`-Hauptdatei überführt und die WAL-Datei auf 0 Bytes kürzt.
2. Das Schließen der Datenbankverbindung durch, woraufhin SQLite die `-wal`- und `-shm`-Dateien vom Dateisystem entfernt.
3. Ein explizites Bereinigungs-Fallback aus (`mount.rs`), das verbleibende Begleitdateien sicher vom Host-Dateisystem löscht.
### 5.3 Abrupter Systemabsturz & OpSec-Überlegungen
Wird der Rechner abrupt stromlos gemacht (`Hard Reset`), stürzt das Betriebssystem ab oder wird der Prozess via `SIGKILL` / Task-Manager beendet, verbleiben `<container>.sanctum-wal` und `-shm` möglicherweise auf dem Datenträger.
- **Datenintegrität**: Beim nächsten Öffnen des Containers führt SQLite automatisch ein WAL-Recovery durch, wodurch keine Daten verloren gehen und die Metadaten-Authentifizierung (K-01) intakt bleibt.
- **Forensische Sichtbarkeit**: Ein Angreifer kann aus der Existenz und Dateigröße der `-wal`-Datei ablesen, dass vor dem Absturz Schreiboperationen stattgefunden haben und wie viele Bytes modifiziert wurden.
- **Flash-Wear-Leveling**: Auf SSDs/NVMe-Speichern können gelöschte Dateisystem-Sektoren bis zur TRIM-Bereinigung physisch im Flash-Speicher existieren. Für maximale OpSec empfiehlt Sanctum die Ablage von Containern auf vollverschlüsselten Host-Laufwerken (BitLocker / LUKS) oder RAM-Disks.
+3 -3
View File
@@ -1,12 +1,12 @@
{
"version": "0.6.0",
"version": "0.8.0",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
"bin": "sanctum.exe"
}
},
+4 -4
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.6.0
PackageVersion: 0.8.0
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
@@ -18,7 +18,7 @@ Tags:
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.0
Installers:
- Architecture: x64
InstallerType: zip
@@ -26,7 +26,7 @@ Installers:
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
ManifestType: singleton
ManifestVersion: 1.6.0
+1 -1
View File
@@ -115,7 +115,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
} else {
+1 -1
View File
@@ -110,7 +110,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
} else {
+247 -26
View File
@@ -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;
@@ -23,7 +23,7 @@ use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::Database;
use crate::vfs::{is_leak_file, SanctumDirEntry, SanctumMetaData};
use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData};
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
pub const CARRIER_VERSION: u32 = 1;
@@ -115,6 +115,7 @@ pub fn read_carrier_block(
&chunk_rec.nonce,
&chunk_rec.tag,
format_version,
chunk_rec.generation,
)?;
// Das outer_decrypted enthält: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding
@@ -149,6 +150,7 @@ pub fn read_carrier_block(
&inner_nonce,
&inner_tag,
format_version,
chunk_rec.generation,
)?;
Ok(inner_plaintext)
@@ -167,6 +169,8 @@ pub fn write_carrier_block(
plaintext: &[u8],
format_version: u32,
) -> Result<()> {
let gen = 0u64;
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
dek_inner,
@@ -174,6 +178,7 @@ pub fn write_carrier_block(
block_idx,
plaintext,
format_version,
gen,
)?;
let inner_ct_len = inner_ct.len() as u32;
@@ -208,12 +213,14 @@ pub fn write_carrier_block(
block_idx,
&outer_plaintext,
format_version,
gen,
)?;
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
db.write_chunk(
carrier_node_id,
block_idx,
gen,
&outer_nonce,
&outer_tag,
&outer_ct,
@@ -234,17 +241,20 @@ pub fn shred_carrier_block(
let mut noise = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut noise);
let gen = 0u64;
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
dek_outer,
carrier_node_id,
block_idx,
&noise,
format_version,
gen,
)?;
db.write_chunk(
carrier_node_id,
block_idx,
gen,
&outer_nonce,
&outer_tag,
&outer_ct,
@@ -261,11 +271,16 @@ pub struct CarrierFsInner {
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
pub format_version: u32,
pub anti_leak: bool,
pub custom_leak_rules: Arc<Vec<String>>,
pub leak_counter: Arc<AtomicU64>,
pub manifest: CarrierManifest,
pub last_activity: Arc<AtomicU64>,
}
impl CarrierFsInner {
pub fn is_leak(&self, name: &str) -> bool {
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
}
pub fn save_manifest(&mut self) -> Result<()> {
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
@@ -359,14 +374,15 @@ pub struct CarrierFs {
}
impl CarrierFs {
/// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei.
pub fn load(
/// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04).
pub fn load_with_leak_rules(
db: Database,
carrier_node_id: i64,
dek_outer: Arc<Zeroizing<[u8; 32]>>,
dek_inner: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Vec<String>,
) -> Result<Self> {
let manifest_bytes = read_carrier_block(
&db,
@@ -398,6 +414,8 @@ impl CarrierFs {
dek_inner,
format_version,
anti_leak,
custom_leak_rules: Arc::new(custom_leak_rules),
leak_counter: Arc::new(AtomicU64::new(0)),
manifest,
last_activity: Arc::new(AtomicU64::new(now)),
};
@@ -407,8 +425,33 @@ impl CarrierFs {
})
}
/// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei.
pub fn load(
db: Database,
carrier_node_id: i64,
dek_outer: Arc<Zeroizing<[u8; 32]>>,
dek_inner: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
) -> Result<Self> {
Self::load_with_leak_rules(
db,
carrier_node_id,
dek_outer,
dek_inner,
format_version,
anti_leak,
Vec::new(),
)
}
pub fn leak_counter(&self) -> Arc<AtomicU64> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.leak_counter.clone()
}
pub fn last_activity(&self) -> Arc<AtomicU64> {
let inner = self.inner.lock().unwrap();
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.last_activity.clone()
}
@@ -439,13 +482,14 @@ impl DavFileSystem for CarrierFs {
let mut inner = self.inner.lock().unwrap();
let (parent_path, file_name) = inner.split_parent_and_name(&path_str);
if inner.anti_leak && is_leak_file(file_name) {
if inner.is_leak(file_name) {
if options.create
|| options.create_new
|| options.write
|| options.append
|| options.truncate
{
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
}
@@ -544,7 +588,14 @@ impl DavFileSystem for CarrierFs {
.inodes
.values()
.filter(|child| child.parent_id == Some(node.id))
.filter(|child| !inner.anti_leak || !is_leak_file(&child.name))
.filter(|child| {
if inner.is_leak(&child.name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
false
} else {
true
}
})
.map(|child| {
Ok(Box::new(SanctumDirEntry {
name: child.name.clone(),
@@ -591,7 +642,8 @@ impl DavFileSystem for CarrierFs {
let mut inner = self.inner.lock().unwrap();
let (parent_path, dir_name) = inner.split_parent_and_name(&path_str);
if inner.anti_leak && is_leak_file(dir_name) {
if inner.is_leak(dir_name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
@@ -624,9 +676,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 +709,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 +738,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(())
})
@@ -708,7 +754,8 @@ impl DavFileSystem for CarrierFs {
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
if inner.anti_leak && is_leak_file(to_name) {
if inner.is_leak(to_name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
@@ -748,13 +795,157 @@ impl DavFileSystem for CarrierFs {
inode.modified_at = now;
}
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
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);
if from_str == to_str {
return Ok(());
}
let mut inner = self.inner.lock().unwrap();
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::NotImplemented);
}
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
if inner.is_leak(to_name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
return Err(FsError::Forbidden);
}
let to_parent = inner
.resolve_path(to_parent_path)
.ok_or(FsError::NotFound)?;
if !to_parent.is_dir {
return Err(FsError::Forbidden);
}
// Falls Zieldatei existiert und ein Verzeichnis ist: Fehler
if let Some(dest) = inner.resolve_path(&to_str) {
if dest.is_dir {
return Err(FsError::Forbidden);
}
for b in dest.blocks {
let _ = shred_carrier_block(
&inner.db,
inner.carrier_node_id,
b,
&inner.dek_outer,
inner.format_version,
);
inner.manifest.free_blocks.push(b);
}
inner.manifest.inodes.remove(&dest.id);
}
if inner.manifest.free_blocks.len() < node.blocks.len() {
return Err(FsError::InsufficientStorage);
}
let mut dest_blocks = Vec::with_capacity(node.blocks.len());
for &src_block_idx in &node.blocks {
let dest_block_idx = match inner.allocate_block() {
Ok(b) => b,
Err(e) => {
for b in dest_blocks {
let _ = inner.free_block(b);
}
return Err(e);
}
};
let data = match read_carrier_block(
&inner.db,
inner.carrier_node_id,
src_block_idx,
&inner.dek_outer,
&inner.dek_inner,
inner.format_version,
) {
Ok(d) => d,
Err(e) => {
error!("Fehler beim Lesen des Quell-Blocks während copy: {e}");
let _ = inner.free_block(dest_block_idx);
for b in dest_blocks {
let _ = inner.free_block(b);
}
return Err(FsError::GeneralFailure);
}
};
if let Err(e) = write_carrier_block(
&inner.db,
inner.carrier_node_id,
dest_block_idx,
&inner.dek_outer,
&inner.dek_inner,
&data,
inner.format_version,
) {
error!("Fehler beim Schreiben des Ziel-Blocks während copy: {e}");
let _ = inner.free_block(dest_block_idx);
for b in dest_blocks {
let _ = inner.free_block(b);
}
return Err(FsError::GeneralFailure);
}
dest_blocks.push(dest_block_idx);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let new_id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let new_inode = CarrierInode {
id: new_id,
parent_id: Some(to_parent.id),
name: to_name.to_string(),
is_dir: false,
size: node.size,
created_at: now,
modified_at: now,
blocks: dest_blocks,
};
inner.manifest.inodes.insert(new_id, new_inode);
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn get_quota(&self) -> FsFuture<'_, (u64, Option<u64>)> {
Box::pin(async move {
self.touch();
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let total_capacity =
inner.manifest.total_blocks as u64 * crate::crypto::CHUNK_SIZE as u64;
let used_bytes = inner
.manifest
.inodes
.values()
.filter(|i| !i.is_dir)
.map(|i| i.size)
.sum::<u64>();
Ok((used_bytes, Some(total_capacity)))
})
}
}
/// Datei-Handle für Dateien innerhalb des Carrier-Dateisystems mit Streaming und Chunk-Pufferung.
@@ -927,6 +1118,35 @@ impl DavFile for CarrierFile {
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
self.touch();
Box::pin(async move {
// V-06: Sparse Writes — Lücke zwischen bisherigem Dateiende und Cursor mit Nullen füllen
if self.cursor > self.file_size {
let target = self.cursor;
while self.file_size < target {
let block_idx = (self.file_size / CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize;
let offset_in_block =
(self.file_size % CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize;
let space_in_block = CARRIER_BLOCK_PAYLOAD_SIZE - offset_in_block;
let to_pad = ((target - self.file_size) as usize).min(space_in_block);
let block_data = self.ensure_block_loaded(block_idx)?;
if block_data.len() < offset_in_block + to_pad {
block_data.resize(offset_in_block + to_pad, 0);
}
let mut is_full = false;
if let Some((_, ref d, ref mut dirty)) = self.cached_block {
*dirty = true;
is_full = d.len() >= CARRIER_BLOCK_PAYLOAD_SIZE;
}
self.file_size += to_pad as u64;
if is_full {
self.flush_cached_block()?;
}
}
}
let mut src = &buf[..];
while !src.is_empty() {
@@ -1011,9 +1231,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 +1243,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();
+497 -73
View File
@@ -7,20 +7,23 @@ use argon2::{Algorithm, Argon2, Params, Version};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION_V1: u32 = 1;
pub const FORMAT_VERSION_V2: u32 = 2;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2;
pub const FORMAT_VERSION_V3: u32 = 3;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V3;
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
pub const COMPRESSION_NONE: u8 = 0x00;
pub const COMPRESSION_LZ4: u8 = 0x01;
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
pub const DEFAULT_TIME_COST: u32 = 3;
pub const DEFAULT_MEMORY_COST_KIB: u32 = 256 * 1024; // 256 MB (262_144 KiB)
pub const DEFAULT_TIME_COST: u32 = 4;
pub const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -101,9 +104,18 @@ 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.
/// RAII-Guard für kurzzeitige Stack-Puffer, um Lock-Leaks bei Fehlern oder Rückgabe zu verhindern (V-03).
struct ScopedMemoryLock(*const u8, usize);
impl Drop for ScopedMemoryLock {
fn drop(&mut self) {
crate::windows::unlock_memory(self.0, self.1);
}
}
pub fn derive_kek(
password: &str,
salt: &[u8],
salt: &[u8; 16],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
@@ -119,12 +131,11 @@ pub fn derive_kek(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
let _lock_guard = ScopedMemoryLock(kek.as_ptr(), 32);
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
if let Err(e) = res {
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
}
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
Ok(kek)
}
@@ -133,6 +144,7 @@ pub fn derive_kek(
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
let _lock_guard = ScopedMemoryLock(dek.as_ptr(), 32);
OsRng.fill_bytes(&mut *dek);
dek
}
@@ -145,10 +157,7 @@ pub fn generate_salt() -> [u8; 16] {
}
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
pub fn wrap_key_payload(
kek: &[u8; 32],
payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -183,17 +192,18 @@ pub fn unwrap_key_payload(
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
.map_err(|_| {
anyhow::anyhow!(
"Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"
)
})?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
@@ -206,7 +216,10 @@ pub fn unwrap_dek(
) -> Result<Zeroizing<[u8; 32]>> {
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
bail!(
"Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}",
payload.len()
);
}
let mut dek = Zeroizing::new([0u8; 32]);
@@ -265,7 +278,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 +303,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 +321,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<String> {
pub fn decrypt_node_name_ext(
dek: &[u8; 32],
parent_id: i64,
stored: &str,
allow_legacy: bool,
) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
@@ -423,7 +446,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 +498,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
cleaned.split_whitespace().map(|s| s.to_string()).collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
@@ -500,9 +524,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
format!(
"Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)",
idx + 1,
word,
sug
)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
format!(
"Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)",
idx + 1,
word
)
};
invalid_words.push(msg);
}
@@ -537,6 +570,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
Ok(dek)
}
/// Berechnet HMAC-SHA256 gemäß RFC 2104.
pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
let mut key_block = [0u8; 64];
if key.len() > 64 {
let mut hasher = Sha256::new();
hasher.update(key);
let hash = hasher.finalize();
key_block[..32].copy_from_slice(&hash);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut k_ipad = [0u8; 64];
let mut k_opad = [0u8; 64];
for i in 0..64 {
k_ipad[i] = key_block[i] ^ 0x36;
k_opad[i] = key_block[i] ^ 0x5c;
}
let mut inner_hasher = Sha256::new();
inner_hasher.update(k_ipad);
inner_hasher.update(data);
let inner_hash = inner_hasher.finalize();
let mut outer_hasher = Sha256::new();
outer_hasher.update(k_opad);
outer_hasher.update(inner_hash);
let out = outer_hasher.finalize();
let mut result = [0u8; 32];
result.copy_from_slice(&out);
result
}
/// Leitet einen 32-Byte Schlüssel mittels HKDF-SHA256 (RFC 5869) aus IKM und Info-String ab.
pub fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let salt = [0u8; 32];
let prk = hmac_sha256(&salt, ikm);
let mut expand_input = Vec::with_capacity(info.len() + 1);
expand_input.extend_from_slice(info);
expand_input.push(0x01);
hmac_sha256(&prk, &expand_input)
}
/// Leitet den dedizierten Metadaten-MAC-Schlüssel aus dem DEK ab (Format V3 / K-01).
pub fn derive_metadata_mac_key(dek: &[u8; 32]) -> [u8; 32] {
hkdf_sha256(dek, b"SANCTUM_META_MAC_V3")
}
/// Berechnet den Metadaten-MAC über kanonisch serialisierte Knoten und Generation (Format V3 / K-01).
pub fn compute_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
) -> [u8; 32] {
let mut data = Vec::with_capacity(24 + canonical_nodes.len());
data.extend_from_slice(b"SANCTUM_META_V3\0");
data.extend_from_slice(&metadata_gen.to_le_bytes());
data.extend_from_slice(canonical_nodes);
hmac_sha256(mac_key, &data)
}
/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC gültig ist (Format V3 / K-01).
pub fn verify_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
expected_mac: &[u8; 32],
) -> bool {
let computed = compute_metadata_mac(mac_key, metadata_gen, canonical_nodes);
computed.ct_eq(expected_mac).into()
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline]
@@ -547,9 +655,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
aad
}
/// Erzeugt die 24-Byte Associated Data (AAD) für einen Chunk in Format V3 (K-02 Chunk-Replay-Schutz):
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian) || generation (8 Bytes Little-Endian).
#[inline]
pub fn build_chunk_aad_v3(node_id: i64, chunk_index: u32, generation: u64) -> [u8; 24] {
let mut aad = [0u8; 24];
aad[..8].copy_from_slice(&node_id.to_le_bytes());
aad[8..16].copy_from_slice(&(chunk_index as u64).to_le_bytes());
aad[16..24].copy_from_slice(&generation.to_le_bytes());
aad
}
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
/// sofern dadurch eine Größenreduktion erzielt wird.
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers verwendet (K-02).
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk(
dek: &[u8; 32],
@@ -557,6 +677,7 @@ pub fn encrypt_chunk(
chunk_index: u32,
plaintext: &[u8],
format_version: u32,
generation: u64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -565,7 +686,15 @@ pub fn encrypt_chunk(
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let aad_16;
let aad_24;
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
&aad_24
} else {
aad_16 = build_chunk_aad(node_id, chunk_index);
&aad_16
};
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
if plaintext.is_empty() {
@@ -590,7 +719,7 @@ pub fn encrypt_chunk(
};
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
.encrypt_in_place_detached(nonce, aad, &mut buffer)
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
@@ -601,6 +730,7 @@ pub fn encrypt_chunk(
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers geprüft (K-02).
pub fn decrypt_chunk(
dek: &[u8; 32],
node_id: i64,
@@ -609,18 +739,32 @@ pub fn decrypt_chunk(
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
format_version: u32,
generation: u64,
) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let aad_16;
let aad_24;
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
&aad_24
} else {
aad_16 = build_chunk_aad(node_id, chunk_index);
&aad_16
};
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)"))?;
.decrypt_in_place_detached(nonce, aad, &mut buffer, tag)
.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 +798,6 @@ pub fn decrypt_chunk(
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -689,6 +832,28 @@ mod tests {
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
}
#[test]
fn test_m01_default_kdf_params_256mib_4_iterations() {
let defaults = KdfParams::default();
assert_eq!(
defaults.memory_cost,
256 * 1024,
"Default memory cost must be 256 MiB (262,144 KiB)"
);
assert_eq!(
defaults.time_cost, 4,
"Default time cost must be 4 iterations"
);
assert_eq!(
defaults.parallelism, 4,
"Default parallelism must be 4 threads"
);
assert!(
validate_kdf_params(&defaults).is_ok(),
"Default KDF parameters must pass validation"
);
}
#[test]
fn test_chunk_encryption_and_swap_protection() {
let dek = generate_dek();
@@ -697,29 +862,174 @@ mod tests {
let chunk_index = 0u32;
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).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,
0,
)
.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,
0,
);
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,
0,
);
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,
0,
)
.is_err());
}
#[test]
fn test_k02_chunk_replay_protection_with_generation_aad() {
let dek = generate_dek();
let plaintext_v1 = b"Original Chunk Data at Generation 1";
let plaintext_v2 = b"Overwritten Chunk Data at Generation 2";
let node_id = 42i64;
let chunk_index = 0u32;
// 1. Chunk mit Generation 1 verschlüsseln
let (ct1, nonce1, tag1) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v1,
FORMAT_VERSION_V3,
1,
)
.unwrap();
// Verifiziere reguläre Entschlüsselung mit Generation 1
let dec1 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
1,
)
.unwrap();
assert_eq!(dec1, plaintext_v1);
// 2. Replay-Schutz: Entschlüsselung mit falscher Generation (z. B. 2) MUSS scheitern!
let replay_err = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
2,
);
assert!(
replay_err.is_err(),
"Ciphertext von Gen 1 darf unter Gen 2 AAD nicht entschlüsselt werden"
);
// 3. Chunk überschreiben mit Generation 2
let (ct2, nonce2, tag2) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v2,
FORMAT_VERSION_V3,
2,
)
.unwrap();
let dec2 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct2,
&nonce2,
&tag2,
FORMAT_VERSION_V3,
2,
)
.unwrap();
assert_eq!(dec2, plaintext_v2);
// 4. Replay-Angriff: Angreifer spielt ct1 (Gen 1) ein, während System Gen 2 erwartet
let attack_res = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
2,
);
assert!(
attack_res.is_err(),
"Replay von altem Ciphertext muss abgewehrt werden"
);
// 5. Abwärtskompatibilität: In V2 wird generation ignoriert
let (ct_v2, nonce_v2, tag_v2) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v1,
FORMAT_VERSION_V2,
0,
)
.unwrap();
let dec_v2 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct_v2,
&nonce_v2,
&tag_v2,
FORMAT_VERSION_V2,
999, // beliebig
)
.unwrap();
assert_eq!(dec_v2, plaintext_v1);
}
#[test]
@@ -732,7 +1042,7 @@ mod tests {
let chunk_index = 0u32;
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap();
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
assert!(
@@ -742,8 +1052,17 @@ 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,
0,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -755,11 +1074,11 @@ mod tests {
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2, 0).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2, 0).unwrap();
assert_eq!(decrypted, random_bytes);
}
@@ -772,11 +1091,20 @@ mod tests {
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1, 0).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,
0,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -788,10 +1116,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 +1150,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 +1202,14 @@ mod tests {
assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
let legacy_format = format!(
"$h${}${}${}",
&encrypted[0..24],
&encrypted[24..56],
&encrypted[56..]
);
let decrypted_legacy =
decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
@@ -869,7 +1217,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 +1234,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 +1257,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 +1291,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 +1302,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 +1335,68 @@ 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,
0,
);
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
);
}
#[test]
fn test_hmac_sha256_rfc4231_test_case_1() {
// RFC 4231 Test Case 1: Key = 20x 0x0b, Data = "Hi There"
let key = [0x0b; 20];
let data = b"Hi There";
let mac = hmac_sha256(&key, data);
let expected_hex = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
assert_eq!(hex::encode(mac), expected_hex);
}
#[test]
fn test_metadata_mac_verification() {
let dek = generate_dek();
let mac_key = derive_metadata_mac_key(&dek);
let canonical_nodes = b"node_canonical_bytes_mock_12345";
let gen = 0u64;
let mac = compute_metadata_mac(&mac_key, gen, canonical_nodes);
assert!(verify_metadata_mac(&mac_key, gen, canonical_nodes, &mac));
// Manipulierte Generation -> ungültig
assert!(!verify_metadata_mac(
&mac_key,
gen + 1,
canonical_nodes,
&mac
));
// Manipulierte Knoten-Bytes -> ungültig
assert!(!verify_metadata_mac(&mac_key, gen, b"tampered_nodes", &mac));
// Falscher Schlüssel -> ungültig
let wrong_dek = generate_dek();
let wrong_key = derive_metadata_mac_key(&wrong_dek);
assert!(!verify_metadata_mac(&wrong_key, gen, canonical_nodes, &mac));
}
}
+580 -112
View File
File diff suppressed because it is too large Load Diff
+207 -82
View File
@@ -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(())
}
@@ -86,6 +84,7 @@ pub async fn mount_container(
idle_timeout: Option<u64>,
lock_on_screen_lock: bool,
anti_leak: bool,
anti_leak_list: Option<&Path>,
stealth: bool,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
@@ -106,8 +105,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 +114,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 +146,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 +159,13 @@ 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 = Zeroizing::new(
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,25 +174,56 @@ 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,
)
}
};
// S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren
let _advisory_lock = db.acquire_advisory_lock_guard(false)?;
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
// Benutzerdefinierte Anti-Leak Filterregeln laden (V-04)
let custom_leak_rules = if let Some(path) = anti_leak_list {
match crate::vfs::load_anti_leak_list(path) {
Ok(rules) => {
if !stealth {
println!(
" • Anti-Leak Liste: {} benutzerdefinierte Regeln geladen",
rules.len()
);
}
rules
}
Err(e) => {
bail!("Fehler beim Laden der Anti-Leak-Regeln: {e}");
}
}
} else {
Vec::new()
};
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
let fs = SanctumFs::with_carrier_and_leak_rules(
db.clone(),
dek,
carrier_dek,
carrier_node_id,
version,
anti_leak,
custom_leak_rules,
vault_id,
);
let fs_leak_counter = fs.leak_counter();
let last_activity = fs.last_activity();
let dav_server = DavHandler::builder()
.filesystem(Box::new(fs))
@@ -340,7 +381,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 +393,14 @@ 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);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
for line in format_linux_mount_instructions(bound_port, &session_token, mount_point) {
println!("{}", line);
}
}
if let Some(secs) = idle_timeout {
@@ -376,9 +422,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 +438,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 +557,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 +585,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),
@@ -545,8 +607,19 @@ pub async fn mount_container(
println!("{}", ui::green("OK"));
}
let blocked_leaks = fs_leak_counter.load(Ordering::Relaxed);
if blocked_leaks > 0 {
println!(
" • Anti-Leak Shield: {} Zugriffe auf Metadaten-/Leak-Dateien blockiert.",
ui::cyan(&blocked_leaks.to_string())
);
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!(
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
ui::green("")
);
println!();
}
@@ -608,6 +681,36 @@ pub fn is_loopback_host(host_str: &str) -> bool {
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
}
/// Erzeugt die plattformspezifischen Linux-Mount-Anweisungen und Authentifizierungsdaten (M-03).
pub fn format_linux_mount_instructions(
port: u16,
token: &str,
mount_point: Option<&std::path::Path>,
) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!(
" • WebDAV-Auth: Benutzer: sanctum | Token: {}",
token
));
lines.push(format!(
" • gio Befehl: echo {} | gio mount dav://sanctum@127.0.0.1:{}/",
token, port
));
if let Some(mp) = mount_point {
lines.push(format!(
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ {}",
port,
mp.display()
));
} else {
lines.push(format!(
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ /mnt/sanctum",
port
));
}
lines
}
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
@@ -616,6 +719,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 +741,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 /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
let path = uri.path();
if !path.starts_with(prefix) {
return None;
/// 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 +783,15 @@ fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
.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<bool>,
) {
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 +820,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 +844,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 (/<session_token>/...)
let mut authenticated = false;
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
@@ -773,15 +879,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 +938,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 +979,41 @@ 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, ""));
}
#[test]
fn test_m03_format_linux_mount_instructions() {
let token = "fedcba9876543210fedcba9876543210";
let instructions =
format_linux_mount_instructions(8443, token, Some(std::path::Path::new("/mnt/secure")));
let all_text = instructions.join("\n");
assert!(all_text.contains("Benutzer: sanctum"));
assert!(all_text.contains(token));
assert!(all_text.contains("gio mount dav://sanctum@127.0.0.1:8443/"));
assert!(all_text
.contains("mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/secure"));
}
}
+17 -5
View File
@@ -1,4 +1,14 @@
use anyhow::{bail, Result};
use anyhow::{bail, Result};
/// Prüft, ob ein Name Path-Traversal-Muster oder Pfadtrennzeichen enthält (S-02).
pub fn is_path_traversal(name: &str) -> bool {
name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
}
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
@@ -7,7 +17,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 +42,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!(
+182 -34
View File
@@ -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(())
}
@@ -234,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) ->
&chunk0.nonce,
&chunk0.tag,
version,
chunk0.generation,
)
.is_ok();
if is_dek0 {
@@ -256,14 +320,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 +339,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 +412,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(())
}
@@ -361,6 +427,73 @@ pub fn restore_header_from_recovery_key(
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
}
/// Führt ein vollständiges Rekeying eines Containers durch (K-03).
/// Generiert einen neuen kryptografischen DEK, verschlüsselt alle Chunks um,
/// verpackt den neuen DEK mit dem bestehenden Passwort und gibt das neue
/// 24-Wort Notfallblatt (BIP-39 Mnemonic) zurück.
/// Das alte Notfallblatt wird dadurch unwiderruflich ungültig.
pub fn rekey_container(container_path: &Path, password: &str) -> Result<String> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
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).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Master-Passwort! Authentifizierung fehlgeschlagen.")
})?;
let old_dek = keys.dek().clone();
let slot_id = keys.slot_id();
let version = keys.version();
let carrier_dek = keys.carrier_dek();
let carrier_node_id = keys.carrier_node_id();
// 1. Neuen DEK generieren
let new_dek = crate::crypto::generate_dek();
// 2. Chunks des aktiven Vaults umverschlüsseln
db.rekey_vault(slot_id, &old_dek, &new_dek, version)?;
// 3. Neuen KEK ableiten und DEK neu verpacken
let new_salt = crate::crypto::generate_salt();
let new_params = KdfParams::default();
let new_kek = derive_kek(password, &new_salt, &new_params)?;
let (new_wrapped_dek, new_nonce, new_tag) = if 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_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &new_dek, &c_dek, c_nid)?
} else {
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot0_payload(&new_kek, &new_dek, c_nid)?
};
db.update_slot_keys(
slot_id,
&new_salt,
&new_params,
&new_wrapped_dek,
&new_nonce,
&new_tag,
)?;
// 4. Metadaten-MAC mit neuem DEK aktualisieren
db.set_active_slot_and_dek(slot_id, new_dek.clone());
db.update_metadata_mac()?;
db.checkpoint()?;
// 5. Neues BIP-39 Notfallblatt generieren
let new_phrase = crate::crypto::dek_to_mnemonic(&new_dek)?;
Ok(new_phrase)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,8 +503,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 +527,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 +552,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 +568,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 +592,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 +609,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);
+1196 -248
View File
File diff suppressed because it is too large Load Diff
+650 -93
View File
File diff suppressed because it is too large Load Diff
+32 -9
View File
@@ -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!();
}
+142 -29
View File
@@ -20,6 +20,8 @@ pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
pub const SANCTUM_RELEASE_PUBKEY_BACKUP: &str =
"RWQEeUSLlX2p4EPUPN1GjeUv34qA0p2B6wJYzcou5oOwAkkw0f+r9FmZ";
/// Repräsentiert ein Asset in einem Gitea-Release
#[derive(Debug, Deserialize, Clone)]
@@ -64,22 +66,84 @@ 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<()> {
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (U-01).
///
/// Erzwingt strikt Pre-Hashing (`allow_legacy = false`), um Malleability- und Längenangriffe auszuschließen.
/// Gibt bei erfolgreicher Verifikation den kryptografisch authentifizierten `trusted comment` zurück.
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<String> {
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
let signature = minisign_verify::Signature::decode(sig_str)
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
pubkey
.verify(data, &signature, true)
.verify(data, &signature, false)
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
Ok(signature.trusted_comment().to_string())
}
/// Extrahiert die Versionsnummer aus dem Trusted Comment einer Minisign-Signatur und
/// validiert sie gegen das erwartete Release-Tag (U-02).
pub fn validate_trusted_comment_version(
trusted_comment: &str,
expected_release: &str,
) -> Result<()> {
let clean_expected = expected_release.trim().trim_start_matches(['v', 'V']);
if clean_expected.is_empty() {
bail!("Erwartetes Release-Tag darf nicht leer sein.");
}
// Durchsuche die Tokens des Trusted Comments (getrennt durch Tabs oder Leerzeichen)
let found = trusted_comment.split_whitespace().any(|token| {
let val = if let Some(stripped) = token.strip_prefix("version:") {
stripped
} else if let Some(stripped) = token.strip_prefix("release:") {
stripped
} else if let Some(stripped) = token.strip_prefix("tag:") {
stripped
} else {
token
};
let clean_val = val.trim_start_matches(['v', 'V']);
clean_val == clean_expected
});
if !found {
bail!(
"Signatur-Validierungsfehler (U-02): Der kryptografisch gesicherte Trusted Comment \
'{}' stimmt nicht mit der erwarteten Release-Version '{}' überein! \
Möglicher Replay-Angriff mit einer älteren signierten Prüfsummendatei.",
trusted_comment.trim(),
expected_release
);
}
Ok(())
}
/// Überprüft eine Minisign-Signatur für ein Release-Manifest.
///
/// Prüft primär gegen `SANCTUM_RELEASE_PUBKEY`. Falls diese Verifikation fehlschlägt,
/// wird transparent gegen den sekundären Backup-Schlüssel `SANCTUM_RELEASE_PUBKEY_BACKUP`
/// geprüft (U-03), um unterbrechungsfreie Schlüsselrotationen zu ermöglichen.
pub fn verify_release_manifest_signature(data: &[u8], sig_str: &str) -> Result<String> {
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY) {
Ok(comment) => Ok(comment),
Err(primary_err) => {
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY_BACKUP) {
Ok(comment) => {
tracing::info!(
"Minisign-Signatur erfolgreich mit sekundärem Backup-Schlüssel verifiziert."
);
Ok(comment)
}
Err(_) => Err(primary_err),
}
}
}
}
/// Erkannter Paketmanager
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
@@ -138,7 +202,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
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 +221,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
/// Lädt eine Datei von einer URL als Text herunter.
pub fn fetch_text(url: &str) -> Result<String> {
let resp = ureq::get(url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(15))
.call()
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
@@ -187,7 +257,7 @@ pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) ->
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
let clean = ver_str.trim().trim_start_matches(['v', 'V']);
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
}
@@ -212,7 +282,10 @@ pub fn download_and_verify_binary(
expected_size: u64,
) -> Result<PathBuf> {
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 +358,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 +400,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 +469,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 +549,47 @@ 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("[-]"));
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
println!(
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
ui::dim("[-]")
);
let trusted_comment = verify_release_manifest_signature(sums_content.as_bytes(), &sig_content)
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
validate_trusted_comment_version(&trusted_comment, remote_tag)?;
println!(
" • Signatur: {} (Minisign Ed25519, Version validiert)",
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 +623,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));
+99 -29
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3};
use crate::storage::Database;
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
@@ -43,11 +43,13 @@ pub fn verify_container(
full_chunks: bool,
) -> Result<VerificationReport> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let mut report = VerificationReport {
container_path: container_path.display().to_string(),
@@ -67,7 +69,8 @@ pub fn verify_container(
};
// 1. SQLite B-Tree & Foreign Key Prüfung
let sqlite_issues = db.run_sqlite_integrity_check()
let sqlite_issues = db
.run_sqlite_integrity_check()
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
if !sqlite_issues.is_empty() {
report.sqlite_ok = false;
@@ -78,7 +81,10 @@ pub fn verify_container(
let meta = match db.read_meta() {
Ok(m) => {
report.format_version = m.version;
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
if m.version != FORMAT_VERSION_V1
&& m.version != FORMAT_VERSION_V2
&& m.version != FORMAT_VERSION_V3
{
report.header_ok = false;
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
}
@@ -96,13 +102,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 +123,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());
}
}
@@ -231,11 +250,36 @@ pub fn verify_container(
None
};
// 3b. Metadaten-Authentifizierung (HMAC-SHA256) für Format V3 (K-01)
if let Some(active_dek) = dek {
if let Some(ref m) = meta {
if m.version >= FORMAT_VERSION_V3 {
match db.verify_metadata_mac(active_dek) {
Ok(true) => {}
Ok(false) => {
report.errors.push(
"Kritischer Metadaten-Authentifizierungsfehler (K-01): Metadaten-MAC ungültig. Manipulationsversuch an Dateinamen, Größen oder Verzeichnisstruktur in der SQLite-Datenbank erkannt.".to_string(),
);
}
Err(e) => {
report
.errors
.push(format!("Fehler bei der Metadaten-MAC-Prüfung: {e}"));
}
}
}
}
}
// 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) {
@@ -257,6 +301,7 @@ pub fn verify_container(
&record.nonce,
&record.tag,
format_version,
record.generation,
) {
Ok(plaintext) => {
report.total_bytes_decrypted += plaintext.len() as u64;
@@ -319,7 +364,9 @@ 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.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Verzeichnis & Datei anlegen
let folder = db.create_node(1, "photos", true).unwrap();
@@ -327,19 +374,30 @@ mod tests {
// 2 Chunks schreiben
let chunk0_data = b"Sample JPEG data header and pixels";
let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
let (ct0, n0, t0) =
encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, 0, &n0, &t0, &ct0).unwrap();
let chunk1_data = b"Additional payload data bytes";
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();
let (ct1, n1, t1) =
encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 1, 0, &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,12 +429,14 @@ 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.set_active_dek(dek.clone());
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";
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -387,14 +447,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);
}
+719 -119
View File
File diff suppressed because it is too large Load Diff
+129 -46
View File
@@ -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<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
std::sync::Mutex::new(None);
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
@@ -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<u16> = drive_str
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let wide_drive: Vec<u16> =
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<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let remote_url = format!("http://127.0.0.1:{}/", port);
let mut remote_name: Vec<u16> = remote_url.encode_utf16().chain(std::iter::once(0)).collect();
let mut remote_name: Vec<u16> = remote_url
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = session_token.encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = 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 {
@@ -813,6 +819,84 @@ pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
Ok(())
}
/// Prüft, ob ein Prozess mit der angegebenen PID auf dem lokalen System aktiv ist (S-06).
pub fn is_process_alive(pid: u32) -> bool {
#[cfg(windows)]
{
extern "system" {
fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> isize;
fn CloseHandle(handle: isize) -> i32;
fn GetExitCodeProcess(handle: isize, exit_code: *mut u32) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const STILL_ACTIVE: u32 = 259;
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return false;
}
let mut exit_code: u32 = 0;
let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
unsafe { CloseHandle(handle) };
success != 0 && exit_code == STILL_ACTIVE
}
#[cfg(not(windows))]
{
std::path::Path::new(&format!("/proc/{}", pid)).exists()
}
}
/// Ermittelt den verfügbaren freien Speicherplatz auf dem Dateisystem des angegebenen Pfads (Z-04).
pub fn get_available_disk_space<P: AsRef<std::path::Path>>(path: P) -> Option<u64> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
let p = path.as_ref();
let dir = if p.is_dir() {
p.to_path_buf()
} else {
p.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_else(|| p.to_path_buf())
};
let mut wide: Vec<u16> = dir.as_os_str().encode_wide().collect();
wide.push(0);
let mut free_bytes_available = 0u64;
let mut total_number_of_bytes = 0u64;
let mut total_number_of_free_bytes = 0u64;
extern "system" {
fn GetDiskFreeSpaceExW(
lpDirectoryName: *const u16,
lpFreeBytesAvailableToCaller: *mut u64,
lpTotalNumberOfBytes: *mut u64,
lpTotalNumberOfFreeBytes: *mut u64,
) -> i32;
}
let success = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free_bytes_available,
&mut total_number_of_bytes,
&mut total_number_of_free_bytes,
)
};
if success != 0 {
Some(free_bytes_available)
} else {
None
}
}
#[cfg(not(windows))]
{
let _ = path;
Some(1024 * 1024 * 1024 * 1024)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -829,7 +913,7 @@ mod tests {
fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive");
assert!(drive.is_ascii_alphabetic());
assert!(drive >= 'D' && drive <= 'Z');
assert!(('D'..='Z').contains(&drive));
}
#[test]
@@ -849,4 +933,3 @@ mod tests {
drop(monitor);
}
}
+488 -35
View File
@@ -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,45 +490,436 @@ 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);
}
#[tokio::test]
async fn test_v01_carrier_fs_copy_delegation_and_functionality() {
let path = temp_db_path("v01_copy");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let carrier_name = "carrier_for_copy.bin";
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let hidden_fs = SanctumFs::with_carrier(
db.clone(),
auth_hidden.dek().clone(),
auth_hidden.carrier_dek(),
auth_hidden.carrier_node_id(),
auth_hidden.version(),
true,
1,
);
// Ordner anlegen
let folder = DavPath::new("/work").unwrap();
hidden_fs.create_dir(&folder).await.expect("Create folder");
// Datei anlegen und Inhalt schreiben
let src_path = DavPath::new("/work/original.txt").unwrap();
let test_data = b"V01 CarrierFs Copy Functionality Test Payload 2026";
let mut file = hidden_fs
.open(
&src_path,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Create source file");
file.write_bytes(Bytes::from_static(test_data))
.await
.expect("Write source data");
file.flush().await.expect("Flush source file");
drop(file);
// V-01: copy von /work/original.txt nach /work/copy.txt aufrufen
let dest_path = DavPath::new("/work/copy.txt").unwrap();
hidden_fs
.copy(&src_path, &dest_path)
.await
.expect("V-01: Copy must succeed on CarrierFs");
// Metadaten der Kopie prüfen
let copy_meta = hidden_fs
.metadata(&dest_path)
.await
.expect("Metadata of copied file");
assert_eq!(copy_meta.len(), test_data.len() as u64);
assert!(!copy_meta.is_dir());
// Inhalt der Kopie lesen und verifizieren
let mut read_copy = hidden_fs
.open(
&dest_path,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open copied file");
let copy_bytes = read_copy.read_bytes(1024).await.expect("Read copy bytes");
assert_eq!(&copy_bytes[..], test_data);
drop(read_copy);
// Quelldatei löschen: Die Kopie muss unabhängig erhalten bleiben!
hidden_fs
.remove_file(&src_path)
.await
.expect("Delete source file");
assert!(hidden_fs.metadata(&src_path).await.is_err());
let mut read_copy_again = hidden_fs
.open(
&dest_path,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open copied file after source delete");
let copy_bytes_2 = read_copy_again
.read_bytes(1024)
.await
.expect("Read copy bytes after source delete");
assert_eq!(&copy_bytes_2[..], test_data);
drop(read_copy_again);
// Overwrite-Copy testen
let src2_path = DavPath::new("/work/source2.txt").unwrap();
let test_data_2 = b"New Overwrite Payload Content";
let mut file2 = hidden_fs
.open(
&src2_path,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Create source2");
file2
.write_bytes(Bytes::from_static(test_data_2))
.await
.expect("Write source2");
file2.flush().await.expect("Flush source2");
drop(file2);
hidden_fs
.copy(&src2_path, &dest_path)
.await
.expect("Overwrite copy must succeed");
let mut read_overwritten = hidden_fs
.open(
&dest_path,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open overwritten copy");
let overwritten_bytes = read_overwritten
.read_bytes(1024)
.await
.expect("Read overwritten bytes");
assert_eq!(&overwritten_bytes[..], test_data_2);
drop(read_overwritten);
// Checkpoint & Verify
db.checkpoint().unwrap();
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
assert!(
report.is_healthy(),
"Container muss integer und gesund bleiben: {:?}",
report.errors
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_m05_deniability_schema_equality_standard_vs_hidden() {
let path_std = temp_db_path("schema_std");
let path_hidden = temp_db_path("schema_hidden");
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_std = generate_salt();
let kek_std = derive_kek("StandardPass123!", &salt_std, &kdf_params).unwrap();
let dek_std = generate_dek();
let (wrapped_std, nonce_std, tag_std) = wrap_slot0_payload(&kek_std, &dek_std, 0).unwrap();
let db_std = Database::open(&path_std).expect("Open std db");
db_std
.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std)
.expect("Init std schema");
db_std.checkpoint().unwrap();
let salt_0 = generate_salt();
let kek_0 = derive_kek("DecoyPass123!", &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek("HiddenPass123!", &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db_hidden = Database::open(&path_hidden).expect("Open hidden db");
db_hidden
.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"carrier.dat",
1024 * 1024,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db_hidden.checkpoint().unwrap();
// Vergleiche Schemas via rusqlite
let conn_std = rusqlite::Connection::open(&path_std).unwrap();
let conn_hidden = rusqlite::Connection::open(&path_hidden).unwrap();
let get_schema_elements =
|conn: &rusqlite::Connection| -> Vec<(String, String, Option<String>)> {
let mut stmt = conn
.prepare(
"SELECT type, name, sql FROM sqlite_master
WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'
ORDER BY type, name",
)
.unwrap();
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.unwrap();
rows.map(|r| r.unwrap()).collect()
};
let schema_std = get_schema_elements(&conn_std);
let schema_hidden = get_schema_elements(&conn_hidden);
assert_eq!(
schema_std, schema_hidden,
"M-05: SQLite-Schema (Tabellen, Indizes, DDL) muss zwischen Standard- und Hidden-Vault identisch sein"
);
// Spalten- und Typinformationen vergleichen
for table in &["meta", "nodes", "chunks"] {
let get_table_info =
|conn: &rusqlite::Connection| -> Vec<(i64, String, String, i64, Option<String>, i64)> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({});", table))
.unwrap();
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, i64>(5)?,
))
})
.unwrap();
rows.map(|r| r.unwrap()).collect()
};
let cols_std = get_table_info(&conn_std);
let cols_hidden = get_table_info(&conn_hidden);
assert_eq!(
cols_std, cols_hidden,
"M-05: Spalten und Typen für Tabelle '{}' müssen identisch sein",
table
);
}
// Pragmas vergleichen (page_size, auto_vacuum, secure_delete)
let get_pragma = |conn: &rusqlite::Connection, pragma: &str| -> i64 {
conn.query_row(&format!("PRAGMA {};", pragma), [], |r| r.get(0))
.unwrap()
};
assert_eq!(
get_pragma(&conn_std, "page_size"),
get_pragma(&conn_hidden, "page_size")
);
assert_eq!(
get_pragma(&conn_std, "auto_vacuum"),
get_pragma(&conn_hidden, "auto_vacuum")
);
assert_eq!(
get_pragma(&conn_std, "secure_delete"),
get_pragma(&conn_hidden, "secure_delete")
);
drop(conn_std);
drop(conn_hidden);
drop(db_std);
drop(db_hidden);
let _ = std::fs::remove_file(&path_std);
let _ = std::fs::remove_file(&path_hidden);
}
+750 -133
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -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");
+211 -15
View File
@@ -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,7 +62,8 @@ 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();
@@ -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,199 @@ 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"
);
}
#[tokio::test]
async fn test_vfs_memory_lock_retention_on_clone_v03() {
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, ReadDirMeta};
use futures_util::StreamExt;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_v03_lock_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassV03!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// 1. Initialisiere SanctumFs
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
assert_eq!(
fs.dek_strong_count(),
1,
"Anfangs muss genau 1 Referenz auf den verriegelten DEK existieren"
);
// 2. Klone SanctumFs (wie bei jeder HTTP/WebDAV-Anfrage im Server)
let fs_clone = fs.clone();
assert_eq!(
fs.dek_strong_count(),
2,
"Nach dem Klonen müssen 2 Referenzen existieren"
);
// 3. Droppe den Klon
drop(fs_clone);
// 4. V-03: Der Refcount muss nun wieder 1 sein. Der Speicherbereich darf NICHT
// vorzeitig über Drop eines Klons entriegelt worden sein!
assert_eq!(
fs.dek_strong_count(),
1,
"Nach Drop des Klons muss genau 1 Referenz erhalten bleiben"
);
// Verifiziere funktionale Nutzbarkeit nach Drop des Klons
let root_path = DavPath::new("/").unwrap();
let mut entries = fs.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
assert!(entries.next().await.is_none());
drop(fs);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_z01_decoy_password_zeroize_memory() {
use sanctum::mount::ContainerAuth;
use zeroize::Zeroizing;
// Test that ContainerAuth properly encapsulates Zeroizing credentials
let raw_pass = "TopSecretDecoyPass2026!".to_string();
let zeroized_pass = Zeroizing::new(raw_pass.clone());
let auth = ContainerAuth::Password(zeroized_pass.clone());
if let ContainerAuth::Password(ref p) = auth {
assert_eq!(p.as_str(), raw_pass.as_str());
} else {
panic!("ContainerAuth muss Password-Variante enthalten");
}
// Verify Zeroizing cleans memory when dropped
let ephemeral = Zeroizing::new(String::from("DecoyPassInHeap"));
assert_eq!(&*ephemeral, "DecoyPassInHeap");
// Explicit drop calls zeroize
drop(ephemeral);
}
#[tokio::test]
async fn test_z04_webdav_quota_report() {
use dav_server::fs::DavFileSystem;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_z04_quota_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassZ04!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Erstelle einen Testknoten mit Chunks
let node = db.create_node(1, "payload.bin", false).unwrap();
let dummy_chunk = vec![0xAAu8; 1024 * 1024]; // 1 MiB
db.write_chunk_and_update_size(
node.id,
0,
0,
&[0u8; 12],
&[0u8; 16],
&dummy_chunk,
1024 * 1024,
123456,
)
.unwrap();
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
// Rufe WebDAV get_quota ab
let (used, total_opt) = fs.get_quota().await.unwrap();
assert!(
used > 0,
"Z-04: Verwendeter Speicherplatz muss > 0 sein (gemeldet: {} Bytes)",
used
);
assert!(
total_opt.is_some(),
"Z-04: Gesamtkapazität muss gemeldet werden"
);
let total = total_opt.unwrap();
assert!(
total >= used,
"Z-04: Gesamtkapazität ({}) muss mindestens dem belegten Speicher ({}) entsprechen",
total,
used
);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
+40 -10
View File
@@ -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);
}
+76 -23
View File
@@ -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,22 +73,38 @@ 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!
let local_paths = HashSet::new();
let excluded_paths = HashSet::new();
let mut stats = SyncStats::default();
let orphan_res = delete_orphans_in_vault(
&db,
@@ -98,20 +113,33 @@ fn test_carrier_protection_in_storage_and_sync() {
1, // Root-Knoten
"",
&local_paths,
&excluded_paths,
&[],
false, // delete_excluded: false
false, // kein dry_run
true, // quiet
&mut stats,
);
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
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,18 +150,29 @@ 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,
delete_excluded: false,
dry_run: false,
checksum: false,
exclude_patterns: Vec::new(),
quiet: true,
force: false,
backup: false,
update: false,
};
let pull_res = sanctum::sync::run_sync(
&db,
@@ -144,9 +183,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 +211,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);
}
+966 -18
View File
File diff suppressed because it is too large Load Diff
+793 -12
View File
@@ -1,9 +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,
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use std::path::PathBuf;
#[test]
fn test_reject_arbitrary_sqlite_database() {
@@ -16,12 +17,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 +113,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 +169,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 +186,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 +216,782 @@ 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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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());
}
#[test]
fn test_k01_metadata_tampering_detected_by_verify() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "SecretMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
let db = Database::open(&db_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
.unwrap();
// Knoten anlegen
let node = db.create_node(1, "secret_financials.xlsx", false).unwrap();
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
db.checkpoint().unwrap();
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
report.errors
);
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
[],
)
.unwrap();
}
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_report.is_healthy(),
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_report.errors
);
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
[node.id],
)
.unwrap();
}
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_name_report.is_healthy(),
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_name_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_name_report.errors
);
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_k01_upgrade_format_v2_to_v3() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf =
temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "UpgradeMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
// Erzeuge bewusst einen V2-Container ohne metadata_mac
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE meta (
slot_id INTEGER NOT NULL 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
);
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index)
);",
)
.unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
sanctum::crypto::MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped_dek,
nonce.as_slice(),
tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
[],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let meta_before = db.read_meta().unwrap();
assert_eq!(meta_before.version, 2);
// Upgrade auf Format V3 durchführen
db.upgrade_to_v3(&dek).unwrap();
db.checkpoint().unwrap();
let meta_after = db.read_meta().unwrap();
assert_eq!(meta_after.version, 3);
// V3-Container muss nach dem Upgrade integer und gesund sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
report.errors
);
let _ = std::fs::remove_file(&db_path);
}
#[tokio::test]
async fn test_k02_chunk_replay_detected_by_vfs_and_crypto() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join(format!(
"k02_replay_test_{}.sanctum",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let password = "TestPasswordK02!";
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(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// 1. Datei im Format V3 anlegen
let file = db.create_node(1, "replay_target.txt", false).unwrap();
let initial_data = b"State 1: Initial secret content in chunk 0.";
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
let (ct1, nonce1, tag1) = encrypt_chunk(
&dek,
file.id,
0,
initial_data,
sanctum::crypto::FORMAT_VERSION_V3,
gen1,
)
.unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen1,
&nonce1,
&tag1,
&ct1,
initial_data.len() as u64,
1000,
)
.unwrap();
db.checkpoint().unwrap();
// Ciphertext-Zeile von Zustand 1 sichern (Nonce, Tag, Ciphertext)
let saved_chunk1 = db.read_chunk(file.id, 0).unwrap().unwrap();
assert_eq!(saved_chunk1.generation, gen1);
// 2. Chunk mit neuem Inhalt überschreiben (Zustand 2)
let updated_data = b"State 2: Updated overwritten content in chunk 0.";
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
assert!(gen2 > gen1, "Generation muss monoton steigen");
let (ct2, nonce2, tag2) = encrypt_chunk(
&dek,
file.id,
0,
updated_data,
sanctum::crypto::FORMAT_VERSION_V3,
gen2,
)
.unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen2,
&nonce2,
&tag2,
&ct2,
updated_data.len() as u64,
2000,
)
.unwrap();
db.checkpoint().unwrap();
let current_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
assert_eq!(current_chunk.generation, gen2);
// 3. Replay-Angriff: Angreifer spielt alte Ciphertext-Zeile von Zustand 1 zurück in die SQLite-Tabelle
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = 0",
rusqlite::params![
saved_chunk1.nonce.as_slice(),
saved_chunk1.tag.as_slice(),
saved_chunk1.ciphertext,
file.id,
],
)
.unwrap();
}
// Entschlüsselungsversuch muss fehlschlagen (AEAD Auth-Fehler wegen AAD-Generationsabweichung)
let replayed_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
let decrypt_res = decrypt_chunk(
&dek,
file.id,
0,
&replayed_chunk.ciphertext,
&replayed_chunk.nonce,
&replayed_chunk.tag,
sanctum::crypto::FORMAT_VERSION_V3,
replayed_chunk.generation,
);
assert!(
decrypt_res.is_err(),
"K-02: Replay von altem Ciphertext in aktuellem Chunk-Slot muss durch AEAD AAD-Mismatch abgewiesen werden!"
);
// Verify muss Replay/Manipulierte Chunks erkennen
let verify_res = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!verify_res.is_healthy() || verify_res.corrupted_chunks > 0,
"Verify muss Replay/Manipulierte Chunks erkennen"
);
let _ = std::fs::remove_file(&db_path);
}
+135 -6
View File
@@ -1,5 +1,7 @@
use sanctum::upgrade::{
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
use sanctum::upgrade::{
run_upgrade, validate_trusted_comment_version, verify_minisign_signature,
verify_release_manifest_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
SANCTUM_RELEASE_PUBKEY_BACKUP,
};
#[test]
@@ -12,7 +14,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 +31,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 +50,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,10 +67,127 @@ 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"),
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
);
}
#[test]
fn test_u01_reject_legacy_unhashed_signature() {
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
let sig_text = "\
untrusted comment: signature from minisign secret key\n\
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
// Gültige pre-hashed Signatur muss weiterhin erfolgreich geprüft werden
assert!(verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY).is_ok());
// Modifiziere die Signatur, um eine Legacy-Signatur (Ed statt ED) zu simulieren
// In Base64 entspricht 'ED' (0x45, 0x44) dem Präfix 'RU'
// 'Ed' (0x45, 0x64) entspricht in Base64 dem Präfix 'RW'
let lines: Vec<&str> = sig_text.lines().collect();
let mut sig_line = lines[1].to_string();
assert!(sig_line.starts_with("RU"));
sig_line.replace_range(0..2, "RW");
let legacy_sig_text = format!("{}\n{}\n{}\n{}\n", lines[0], sig_line, lines[2], lines[3]);
let res = verify_minisign_signature(payload, &legacy_sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(
res.is_err(),
"U-01: Legacy-Signaturen (ohne Pre-Hashing) müssen strikt abgewiesen werden"
);
}
#[test]
fn test_u02_validate_trusted_comment_version_matching() {
// Standard Minisign format with -t "version:0.8.0"
let tc1 = "timestamp:1789750897\tversion:0.8.0\tfile:SHA256SUMS.txt\thashed";
assert!(validate_trusted_comment_version(tc1, "0.8.0").is_ok());
assert!(validate_trusted_comment_version(tc1, "v0.8.0").is_ok());
// Alternative tags like tag:v0.8.0 or release:0.8.0
let tc2 = "release:0.8.0";
assert!(validate_trusted_comment_version(tc2, "0.8.0").is_ok());
let tc3 = "tag:v0.8.0";
assert!(validate_trusted_comment_version(tc3, "0.8.0").is_ok());
}
#[test]
fn test_u02_reject_mismatched_release_tag_replay() {
// An attacker replays a valid signature from v0.7.0 for an update claiming to be v0.8.0
let tc_old = "timestamp:1789750897\tversion:0.7.0\tfile:SHA256SUMS.txt\thashed";
let res = validate_trusted_comment_version(tc_old, "v0.8.0");
assert!(
res.is_err(),
"U-02: Ältere oder abweichende Version im Trusted Comment muss abgewiesen werden"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("U-02") && err_msg.contains("Replay-Angriff"),
"Fehlermeldung muss auf Replay-Angriff und U-02 hinweisen: {err_msg}"
);
// Empty or completely missing version token
let tc_no_ver = "timestamp:1789750897\tfile:SHA256SUMS.txt\thashed";
let res2 = validate_trusted_comment_version(tc_no_ver, "0.8.0");
assert!(res2.is_err());
}
#[test]
fn test_u03_verify_signature_with_backup_key_fallback() {
let payload = b"Hello Sanctum Release 0.8.0 Backup Key Testing\n";
let backup_sig = "\
untrusted comment: signature from minisign secret key\n\
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
trusted comment: version:0.8.0\n\
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
// 1. Direct verification against primary key MUST fail (key rotation scenario)
let prim_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY);
assert!(
prim_res.is_err(),
"Backup-Signatur darf nicht gegen den Primärschlüssel verifizieren"
);
// 2. Direct verification against backup key MUST succeed
let back_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY_BACKUP);
assert!(
back_res.is_ok(),
"Backup-Signatur muss gegen SANCTUM_RELEASE_PUBKEY_BACKUP gültig sein: {:?}",
back_res.err()
);
// 3. Fallback verification via verify_release_manifest_signature MUST succeed transparently
let fallback_res = verify_release_manifest_signature(payload, backup_sig);
assert!(
fallback_res.is_ok(),
"verify_release_manifest_signature muss via Backup-Schlüssel erfolgreich sein"
);
assert_eq!(fallback_res.unwrap(), "version:0.8.0");
}
#[test]
fn test_u03_verify_signature_rejects_untrusted_third_party_key() {
let fake_sig = "\
untrusted comment: signature from minisign secret key\n\
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
trusted comment: version:0.8.0\n\
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
// Tampered payload with neither primary nor backup key matching
let tampered = b"Tampered data not signed by either key";
let res = verify_release_manifest_signature(tampered, fake_sig);
assert!(
res.is_err(),
"Weder Primär- noch Backup-Schlüssel dürfen manipulierte Nutzdaten akzeptieren"
);
}
+2 -1
View File
@@ -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);