Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6d54d5ca | ||
|
|
84b6a39f3f | ||
|
|
28a4bb6b70 | ||
|
|
45572b7370 | ||
|
|
17908a64fe | ||
|
|
487f999a24 | ||
|
|
5a1fd7cdd8 | ||
|
|
07afe34ad4 | ||
|
|
bb86447039 | ||
|
|
8dd54494b0 | ||
|
|
78a9c233f8 | ||
|
|
c7c0ed85f5 | ||
|
|
92db21d47e | ||
|
|
15adf79f48 | ||
|
|
1ba67cc9e2 | ||
|
|
708da24d91 | ||
|
|
730393423b | ||
|
|
e300a764c9 | ||
|
|
17ac4f9358 | ||
|
|
a2c16eda72 | ||
|
|
e496c418c5 | ||
|
|
2af14eef36 | ||
|
|
ad9148d867 | ||
|
|
a502845662 | ||
|
|
52859623c2 | ||
|
|
f9733dfc73 | ||
|
|
9634beb744 | ||
|
|
f557a9ddfa | ||
|
|
888c86ca5e | ||
|
|
7b0f061221 | ||
|
|
bdddd812ac | ||
|
|
36a4094336 | ||
|
|
ad531d8393 | ||
|
|
151fdba09d | ||
|
|
533764487a | ||
|
|
374150e5b3 | ||
|
|
fba7f305e3 |
@@ -6,16 +6,18 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets
|
||||||
build:
|
build:
|
||||||
name: Build & Release (Windows x86_64)
|
name: Build & Test (Windows x86_64)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned
|
||||||
with:
|
with:
|
||||||
|
toolchain: "1.85.0"
|
||||||
targets: x86_64-pc-windows-msvc
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@@ -47,21 +49,54 @@ jobs:
|
|||||||
|
|
||||||
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
|
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
|
||||||
|
|
||||||
|
|
||||||
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
|
||||||
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Sign Checksums with Minisign
|
- 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
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
run: |
|
run: |
|
||||||
|
# SA-02: Secret-Isolation — MINISIGN_SECRET_KEY ist ausschließlich in diesem dedizierten Signier-Job verfügbar
|
||||||
if (-not $env:MINISIGN_SECRET_KEY) {
|
if (-not $env:MINISIGN_SECRET_KEY) {
|
||||||
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
|
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
|
||||||
exit 1
|
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)) {
|
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"
|
Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin"
|
||||||
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
|
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
|
||||||
} else {
|
} else {
|
||||||
@@ -81,14 +116,12 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/SHA256SUMS.txt
|
dist/SHA256SUMS.txt
|
||||||
dist/SHA256SUMS.txt.minisig
|
dist/SHA256SUMS.txt.minisig
|
||||||
target/release/sanctum.exe
|
|
||||||
body_path: CHANGELOG.md
|
body_path: CHANGELOG.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
+112
@@ -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/)
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
|
||||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [0.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
|
## [0.7.1] - 2026-09-18
|
||||||
|
|
||||||
### Security Patch Release (Closing Gaps R-01 to R-06)
|
### Security Patch Release (Closing Gaps R-01 to R-06)
|
||||||
|
|||||||
Generated
+2
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1483,6 +1483,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||||
@@ -42,6 +42,7 @@ ureq = { version = "2.10", default-features = false, features = ["json", "tls"]
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
semver = "1.0"
|
semver = "1.0"
|
||||||
minisign-verify = "0.2"
|
minisign-verify = "0.2"
|
||||||
|
subtle = "2.6"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
@@ -53,3 +54,18 @@ lto = true
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "unwind"
|
panic = "unwind"
|
||||||
strip = true
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
|
|||||||
### B. Eigenverantwortung für Backups & Notfallschlüssel
|
### 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.
|
* 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.
|
* 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
@@ -73,7 +73,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode
|
|||||||
```powershell
|
```powershell
|
||||||
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
|
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).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
||||||
- **Dateinamen-Verschlüsselung**:
|
- **Dateinamen-Verschlüsselung**:
|
||||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||||
- **Kryptografisches Chunk-Shredding**:
|
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
||||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
|
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.
|
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**:
|
- **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.
|
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).
|
- **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`).
|
- **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.
|
- **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.
|
- **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
|
```powershell
|
||||||
|
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
|
||||||
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
|
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):
|
# 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"
|
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
|
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):
|
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
|
||||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
|
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
|
||||||
|
|
||||||
@@ -290,6 +309,7 @@ Erzeugt:
|
|||||||
## 📄 Lizenz & Changelog
|
## 📄 Lizenz & Changelog
|
||||||
|
|
||||||
- Lizenziert unter der [MIT License](LICENSE).
|
- 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.
|
- 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.
|
- 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).
|
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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",
|
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
|
||||||
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"architecture": {
|
"architecture": {
|
||||||
"64bit": {
|
"64bit": {
|
||||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
|
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
|
||||||
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
|
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
|
||||||
"bin": "sanctum.exe"
|
"bin": "sanctum.exe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||||
PackageIdentifier: HaraldPansi.Sanctum
|
PackageIdentifier: HaraldPansi.Sanctum
|
||||||
PackageVersion: 0.6.0
|
PackageVersion: 0.8.0
|
||||||
PackageName: Sanctum
|
PackageName: Sanctum
|
||||||
Publisher: Harald Pansi
|
Publisher: Harald Pansi
|
||||||
PublisherUrl: https://gitea.pansi.eu/harald
|
PublisherUrl: https://gitea.pansi.eu/harald
|
||||||
@@ -18,7 +18,7 @@ Tags:
|
|||||||
- webdav
|
- webdav
|
||||||
- container
|
- container
|
||||||
- plausible-deniability
|
- 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:
|
Installers:
|
||||||
- Architecture: x64
|
- Architecture: x64
|
||||||
InstallerType: zip
|
InstallerType: zip
|
||||||
@@ -26,7 +26,7 @@ Installers:
|
|||||||
NestedInstallerFiles:
|
NestedInstallerFiles:
|
||||||
- RelativeFilePath: sanctum.exe
|
- RelativeFilePath: sanctum.exe
|
||||||
PortableCommandAlias: sanctum
|
PortableCommandAlias: sanctum
|
||||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
|
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
|
||||||
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
|
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
|
||||||
ManifestType: singleton
|
ManifestType: singleton
|
||||||
ManifestVersion: 1.6.0
|
ManifestVersion: 1.6.0
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
|||||||
if (Test-Path $KeyFile) {
|
if (Test-Path $KeyFile) {
|
||||||
if (Test-Path $MinisignExe) {
|
if (Test-Path $MinisignExe) {
|
||||||
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
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)) {
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
|||||||
if (Test-Path $KeyFile) {
|
if (Test-Path $KeyFile) {
|
||||||
if (Test-Path $MinisignExe) {
|
if (Test-Path $MinisignExe) {
|
||||||
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
|
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)) {
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||||
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+247
-26
@@ -10,8 +10,8 @@ use bytes::{Buf, Bytes, BytesMut};
|
|||||||
use dav_server::{
|
use dav_server::{
|
||||||
davpath::DavPath,
|
davpath::DavPath,
|
||||||
fs::{
|
fs::{
|
||||||
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream,
|
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, OpenOptions,
|
||||||
OpenOptions, ReadDirMeta,
|
ReadDirMeta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
@@ -23,7 +23,7 @@ use zeroize::{Zeroize, Zeroizing};
|
|||||||
|
|
||||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||||
use crate::storage::Database;
|
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_MAGIC: &[u8; 8] = b"SANCTCAR";
|
||||||
pub const CARRIER_VERSION: u32 = 1;
|
pub const CARRIER_VERSION: u32 = 1;
|
||||||
@@ -115,6 +115,7 @@ pub fn read_carrier_block(
|
|||||||
&chunk_rec.nonce,
|
&chunk_rec.nonce,
|
||||||
&chunk_rec.tag,
|
&chunk_rec.tag,
|
||||||
format_version,
|
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
|
// 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_nonce,
|
||||||
&inner_tag,
|
&inner_tag,
|
||||||
format_version,
|
format_version,
|
||||||
|
chunk_rec.generation,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(inner_plaintext)
|
Ok(inner_plaintext)
|
||||||
@@ -167,6 +169,8 @@ pub fn write_carrier_block(
|
|||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let gen = 0u64;
|
||||||
|
|
||||||
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
|
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
|
||||||
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
|
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
|
||||||
dek_inner,
|
dek_inner,
|
||||||
@@ -174,6 +178,7 @@ pub fn write_carrier_block(
|
|||||||
block_idx,
|
block_idx,
|
||||||
plaintext,
|
plaintext,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let inner_ct_len = inner_ct.len() as u32;
|
let inner_ct_len = inner_ct.len() as u32;
|
||||||
@@ -208,12 +213,14 @@ pub fn write_carrier_block(
|
|||||||
block_idx,
|
block_idx,
|
||||||
&outer_plaintext,
|
&outer_plaintext,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
|
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
|
||||||
db.write_chunk(
|
db.write_chunk(
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
|
gen,
|
||||||
&outer_nonce,
|
&outer_nonce,
|
||||||
&outer_tag,
|
&outer_tag,
|
||||||
&outer_ct,
|
&outer_ct,
|
||||||
@@ -234,17 +241,20 @@ pub fn shred_carrier_block(
|
|||||||
let mut noise = vec![0u8; CHUNK_SIZE];
|
let mut noise = vec![0u8; CHUNK_SIZE];
|
||||||
OsRng.fill_bytes(&mut noise);
|
OsRng.fill_bytes(&mut noise);
|
||||||
|
|
||||||
|
let gen = 0u64;
|
||||||
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
||||||
dek_outer,
|
dek_outer,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
&noise,
|
&noise,
|
||||||
format_version,
|
format_version,
|
||||||
|
gen,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
db.write_chunk(
|
db.write_chunk(
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
block_idx,
|
block_idx,
|
||||||
|
gen,
|
||||||
&outer_nonce,
|
&outer_nonce,
|
||||||
&outer_tag,
|
&outer_tag,
|
||||||
&outer_ct,
|
&outer_ct,
|
||||||
@@ -261,11 +271,16 @@ pub struct CarrierFsInner {
|
|||||||
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
||||||
pub format_version: u32,
|
pub format_version: u32,
|
||||||
pub anti_leak: bool,
|
pub anti_leak: bool,
|
||||||
|
pub custom_leak_rules: Arc<Vec<String>>,
|
||||||
|
pub leak_counter: Arc<AtomicU64>,
|
||||||
pub manifest: CarrierManifest,
|
pub manifest: CarrierManifest,
|
||||||
pub last_activity: Arc<AtomicU64>,
|
pub last_activity: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierFsInner {
|
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<()> {
|
pub fn save_manifest(&mut self) -> Result<()> {
|
||||||
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
|
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
|
||||||
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
|
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
|
||||||
@@ -359,14 +374,15 @@ pub struct CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierFs {
|
impl CarrierFs {
|
||||||
/// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei.
|
/// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04).
|
||||||
pub fn load(
|
pub fn load_with_leak_rules(
|
||||||
db: Database,
|
db: Database,
|
||||||
carrier_node_id: i64,
|
carrier_node_id: i64,
|
||||||
dek_outer: Arc<Zeroizing<[u8; 32]>>,
|
dek_outer: Arc<Zeroizing<[u8; 32]>>,
|
||||||
dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
dek_inner: Arc<Zeroizing<[u8; 32]>>,
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
|
custom_leak_rules: Vec<String>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let manifest_bytes = read_carrier_block(
|
let manifest_bytes = read_carrier_block(
|
||||||
&db,
|
&db,
|
||||||
@@ -398,6 +414,8 @@ impl CarrierFs {
|
|||||||
dek_inner,
|
dek_inner,
|
||||||
format_version,
|
format_version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules: Arc::new(custom_leak_rules),
|
||||||
|
leak_counter: Arc::new(AtomicU64::new(0)),
|
||||||
manifest,
|
manifest,
|
||||||
last_activity: Arc::new(AtomicU64::new(now)),
|
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> {
|
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()
|
inner.last_activity.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,13 +482,14 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
let (parent_path, file_name) = inner.split_parent_and_name(&path_str);
|
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
|
if options.create
|
||||||
|| options.create_new
|
|| options.create_new
|
||||||
|| options.write
|
|| options.write
|
||||||
|| options.append
|
|| options.append
|
||||||
|| options.truncate
|
|| options.truncate
|
||||||
{
|
{
|
||||||
|
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
|
||||||
return Err(FsError::Forbidden);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,7 +588,14 @@ impl DavFileSystem for CarrierFs {
|
|||||||
.inodes
|
.inodes
|
||||||
.values()
|
.values()
|
||||||
.filter(|child| child.parent_id == Some(node.id))
|
.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| {
|
.map(|child| {
|
||||||
Ok(Box::new(SanctumDirEntry {
|
Ok(Box::new(SanctumDirEntry {
|
||||||
name: child.name.clone(),
|
name: child.name.clone(),
|
||||||
@@ -591,7 +642,8 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
let (parent_path, dir_name) = inner.split_parent_and_name(&path_str);
|
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);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,9 +676,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_dir);
|
inner.manifest.inodes.insert(new_id, new_dir);
|
||||||
inner
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
.save_manifest()
|
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -659,9 +709,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
.save_manifest()
|
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -690,9 +738,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
.save_manifest()
|
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -708,7 +754,8 @@ impl DavFileSystem for CarrierFs {
|
|||||||
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
|
let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?;
|
||||||
|
|
||||||
let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str);
|
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);
|
return Err(FsError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,13 +795,157 @@ impl DavFileSystem for CarrierFs {
|
|||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
.save_manifest()
|
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
|
||||||
|
|
||||||
Ok(())
|
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.
|
/// 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<'_, ()> {
|
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
||||||
self.touch();
|
self.touch();
|
||||||
Box::pin(async move {
|
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[..];
|
let mut src = &buf[..];
|
||||||
|
|
||||||
while !src.is_empty() {
|
while !src.is_empty() {
|
||||||
@@ -1011,9 +1231,7 @@ impl DavFile for CarrierFile {
|
|||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner
|
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||||
.save_manifest()
|
|
||||||
.map_err(|_| FsError::GeneralFailure)?;
|
|
||||||
|
|
||||||
self.meta.size = self.file_size;
|
self.meta.size = self.file_size;
|
||||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||||
@@ -1025,7 +1243,10 @@ impl DavFile for CarrierFile {
|
|||||||
impl Drop for CarrierFile {
|
impl Drop for CarrierFile {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Err(e) = self.flush_cached_block() {
|
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 {
|
if let Some((_, ref mut data, _)) = self.cached_block {
|
||||||
data.zeroize();
|
data.zeroize();
|
||||||
|
|||||||
+496
-72
@@ -7,20 +7,23 @@ use argon2::{Algorithm, Argon2, Params, Version};
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
||||||
pub const FORMAT_VERSION_V1: u32 = 1;
|
pub const FORMAT_VERSION_V1: u32 = 1;
|
||||||
pub const FORMAT_VERSION_V2: u32 = 2;
|
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
|
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
|
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
|
||||||
pub const COMPRESSION_NONE: u8 = 0x00;
|
pub const COMPRESSION_NONE: u8 = 0x00;
|
||||||
pub const COMPRESSION_LZ4: u8 = 0x01;
|
pub const COMPRESSION_LZ4: u8 = 0x01;
|
||||||
|
|
||||||
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
|
pub const DEFAULT_MEMORY_COST_KIB: u32 = 256 * 1024; // 256 MB (262_144 KiB)
|
||||||
pub const DEFAULT_TIME_COST: u32 = 3;
|
pub const DEFAULT_TIME_COST: u32 = 4;
|
||||||
pub const DEFAULT_PARALLELISM: u32 = 4;
|
pub const DEFAULT_PARALLELISM: u32 = 4;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[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.
|
/// 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(
|
pub fn derive_kek(
|
||||||
password: &str,
|
password: &str,
|
||||||
salt: &[u8],
|
salt: &[u8; 16],
|
||||||
params: &KdfParams,
|
params: &KdfParams,
|
||||||
) -> Result<Zeroizing<[u8; 32]>> {
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
validate_kdf_params(params)?;
|
validate_kdf_params(params)?;
|
||||||
@@ -119,12 +131,11 @@ pub fn derive_kek(
|
|||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
let mut kek = Zeroizing::new([0u8; 32]);
|
let mut kek = Zeroizing::new([0u8; 32]);
|
||||||
let _ = crate::windows::lock_memory(kek.as_ptr(), 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);
|
argon2
|
||||||
if let Err(e) = res {
|
.hash_password_into(password.as_bytes(), salt, &mut *kek)
|
||||||
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
|
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
|
||||||
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(kek)
|
Ok(kek)
|
||||||
}
|
}
|
||||||
@@ -133,6 +144,7 @@ pub fn derive_kek(
|
|||||||
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
|
||||||
let mut dek = Zeroizing::new([0u8; 32]);
|
let mut dek = Zeroizing::new([0u8; 32]);
|
||||||
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
|
||||||
|
let _lock_guard = ScopedMemoryLock(dek.as_ptr(), 32);
|
||||||
OsRng.fill_bytes(&mut *dek);
|
OsRng.fill_bytes(&mut *dek);
|
||||||
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.
|
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
|
||||||
pub fn wrap_key_payload(
|
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||||
kek: &[u8; 32],
|
|
||||||
payload: &[u8],
|
|
||||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
|
||||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
.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();
|
let mut buffer = wrapped_payload.to_vec();
|
||||||
cipher
|
cipher
|
||||||
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
|
.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))
|
Ok(Zeroizing::new(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
|
/// 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.
|
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
|
||||||
pub fn wrap_dek(
|
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||||
kek: &[u8; 32],
|
|
||||||
dek: &[u8; 32],
|
|
||||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
|
||||||
wrap_key_payload(kek, dek)
|
wrap_key_payload(kek, dek)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +216,10 @@ pub fn unwrap_dek(
|
|||||||
) -> Result<Zeroizing<[u8; 32]>> {
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
|
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
|
||||||
if payload.len() < 32 {
|
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]);
|
let mut dek = Zeroizing::new([0u8; 32]);
|
||||||
@@ -265,7 +278,8 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
|||||||
aad
|
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).
|
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
|
||||||
pub fn set_allow_legacy_names(allow: bool) {
|
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 mut buffer = name.as_bytes().to_vec();
|
||||||
let aad = build_name_aad(parent_id);
|
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,
|
Ok(t) => t,
|
||||||
Err(_) => return String::new(),
|
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.
|
/// 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
|
/// 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).
|
/// 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
|
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
||||||
if let Some(rest) = stored.strip_prefix("$h$") {
|
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||||
let parts: Vec<&str> = rest.split('$').collect();
|
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 i in 1..=m {
|
||||||
for j in 1..=n {
|
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)
|
dp[i][j] = (dp[i - 1][j] + 1)
|
||||||
.min(dp[i][j - 1] + 1)
|
.min(dp[i][j - 1] + 1)
|
||||||
.min(dp[i - 1][j - 1] + cost);
|
.min(dp[i - 1][j - 1] + cost);
|
||||||
@@ -471,10 +498,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
cleaned
|
cleaned.split_whitespace().map(|s| s.to_string()).collect()
|
||||||
.split_whitespace()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
|
/// 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()) {
|
if !word_list.contains(&word.as_str()) {
|
||||||
let suggestion = suggest_bip39_word(word);
|
let suggestion = suggest_bip39_word(word);
|
||||||
let msg = if let Some(sug) = suggestion {
|
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 {
|
} 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);
|
invalid_words.push(msg);
|
||||||
}
|
}
|
||||||
@@ -537,6 +570,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
|||||||
Ok(dek)
|
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:
|
/// 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).
|
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -547,9 +655,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
|
|||||||
aad
|
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.
|
/// 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,
|
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
|
||||||
/// sofern dadurch eine Größenreduktion erzielt wird.
|
/// 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.
|
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
|
||||||
pub fn encrypt_chunk(
|
pub fn encrypt_chunk(
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
@@ -557,6 +677,7 @@ pub fn encrypt_chunk(
|
|||||||
chunk_index: u32,
|
chunk_index: u32,
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
|
generation: u64,
|
||||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||||
@@ -565,7 +686,15 @@ pub fn encrypt_chunk(
|
|||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
let nonce = Nonce::from_slice(&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 {
|
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
|
||||||
if plaintext.is_empty() {
|
if plaintext.is_empty() {
|
||||||
@@ -590,7 +719,7 @@ pub fn encrypt_chunk(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tag = cipher
|
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}"))?;
|
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
|
||||||
|
|
||||||
let mut tag_bytes = [0u8; 16];
|
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.
|
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
|
||||||
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
|
/// 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(
|
pub fn decrypt_chunk(
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
node_id: i64,
|
node_id: i64,
|
||||||
@@ -609,18 +739,32 @@ pub fn decrypt_chunk(
|
|||||||
nonce_bytes: &[u8; 12],
|
nonce_bytes: &[u8; 12],
|
||||||
tag_bytes: &[u8; 16],
|
tag_bytes: &[u8; 16],
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
|
generation: u64,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||||
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
let tag = Tag::from_slice(tag_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();
|
let mut buffer = ciphertext.to_vec();
|
||||||
cipher
|
cipher
|
||||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
.decrypt_in_place_detached(nonce, aad, &mut buffer, tag)
|
||||||
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if format_version >= FORMAT_VERSION_V2 {
|
if format_version >= FORMAT_VERSION_V2 {
|
||||||
if buffer.is_empty() {
|
if buffer.is_empty() {
|
||||||
@@ -654,7 +798,6 @@ pub fn decrypt_chunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -689,6 +832,28 @@ mod tests {
|
|||||||
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
|
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]
|
#[test]
|
||||||
fn test_chunk_encryption_and_swap_protection() {
|
fn test_chunk_encryption_and_swap_protection() {
|
||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
@@ -697,29 +862,174 @@ mod tests {
|
|||||||
let chunk_index = 0u32;
|
let chunk_index = 0u32;
|
||||||
|
|
||||||
let (ciphertext, nonce, tag) =
|
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)
|
// Reguläre Entschlüsselung (v2)
|
||||||
let decrypted =
|
let decrypted = decrypt_chunk(
|
||||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce,
|
||||||
|
&tag,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
|
|
||||||
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
||||||
let swap_node_err =
|
let swap_node_err = decrypt_chunk(
|
||||||
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
&dek,
|
||||||
|
99i64,
|
||||||
|
chunk_index,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce,
|
||||||
|
&tag,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
|
);
|
||||||
assert!(swap_node_err.is_err());
|
assert!(swap_node_err.is_err());
|
||||||
|
|
||||||
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
||||||
let swap_idx_err =
|
let swap_idx_err = decrypt_chunk(
|
||||||
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
&dek,
|
||||||
|
node_id,
|
||||||
|
1u32,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce,
|
||||||
|
&tag,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
|
);
|
||||||
assert!(swap_idx_err.is_err());
|
assert!(swap_idx_err.is_err());
|
||||||
|
|
||||||
// Manipulation des Ciphertexts
|
// Manipulation des Ciphertexts
|
||||||
let mut tampered_ct = ciphertext.clone();
|
let mut tampered_ct = ciphertext.clone();
|
||||||
tampered_ct[0] ^= 0x01;
|
tampered_ct[0] ^= 0x01;
|
||||||
assert!(
|
assert!(decrypt_chunk(
|
||||||
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
|
&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]
|
#[test]
|
||||||
@@ -732,7 +1042,7 @@ mod tests {
|
|||||||
let chunk_index = 0u32;
|
let chunk_index = 0u32;
|
||||||
|
|
||||||
let (ciphertext, nonce, tag) =
|
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
|
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
|
||||||
assert!(
|
assert!(
|
||||||
@@ -742,8 +1052,17 @@ mod tests {
|
|||||||
plaintext.len()
|
plaintext.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
let decrypted =
|
let decrypted = decrypt_chunk(
|
||||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce,
|
||||||
|
&tag,
|
||||||
|
FORMAT_VERSION_V2,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,11 +1074,11 @@ mod tests {
|
|||||||
OsRng.fill_bytes(&mut random_bytes);
|
OsRng.fill_bytes(&mut random_bytes);
|
||||||
|
|
||||||
let (ct, nonce, tag) =
|
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
|
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
||||||
assert_eq!(ct.len(), random_bytes.len() + 1);
|
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);
|
assert_eq!(decrypted, random_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,11 +1091,20 @@ mod tests {
|
|||||||
|
|
||||||
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
||||||
let (ciphertext, nonce, tag) =
|
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());
|
assert_eq!(ciphertext.len(), plaintext.len());
|
||||||
|
|
||||||
let decrypted =
|
let decrypted = decrypt_chunk(
|
||||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
|
&dek,
|
||||||
|
node_id,
|
||||||
|
chunk_index,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce,
|
||||||
|
&tag,
|
||||||
|
FORMAT_VERSION_V1,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,10 +1116,17 @@ mod tests {
|
|||||||
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
|
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
|
||||||
|
|
||||||
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
|
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)
|
// 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");
|
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
|
||||||
assert_eq!(*dek, *recovered_messy);
|
assert_eq!(*dek, *recovered_messy);
|
||||||
}
|
}
|
||||||
@@ -815,9 +1150,16 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
|
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
|
||||||
let original_last = words[23].clone();
|
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(" ");
|
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]
|
#[test]
|
||||||
@@ -860,8 +1202,14 @@ mod tests {
|
|||||||
assert_eq!(decrypted, filename);
|
assert_eq!(decrypted, filename);
|
||||||
|
|
||||||
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
|
// 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 legacy_format = format!(
|
||||||
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
"$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);
|
assert_eq!(decrypted_legacy, filename);
|
||||||
|
|
||||||
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
|
// 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 mut static_buf = filename.as_bytes().to_vec();
|
||||||
let static_nonce = [42u8; 12];
|
let static_nonce = [42u8; 12];
|
||||||
let static_tag = cipher
|
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();
|
.unwrap();
|
||||||
let legacy_static_format = format!(
|
let legacy_static_format = format!(
|
||||||
"$h${}${}${}",
|
"$h${}${}${}",
|
||||||
@@ -882,7 +1234,8 @@ mod tests {
|
|||||||
|
|
||||||
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
|
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
|
||||||
set_allow_legacy_names(true);
|
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);
|
assert_eq!(decrypted_static, filename);
|
||||||
set_allow_legacy_names(false);
|
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_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||||
// Gültige parent_ids entschlüsseln erfolgreich
|
// Gültige parent_ids entschlüsseln erfolgreich
|
||||||
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
assert_eq!(
|
||||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
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
|
// 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!(
|
||||||
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
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]
|
#[test]
|
||||||
fn test_model_a_slot_payloads() {
|
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_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||||
let dek_0 = generate_dek();
|
let dek_0 = generate_dek();
|
||||||
@@ -922,7 +1291,8 @@ mod tests {
|
|||||||
let carrier_node_id = 42i64;
|
let carrier_node_id = 42i64;
|
||||||
|
|
||||||
// Slot 0 Payload: 40 Bytes
|
// 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);
|
assert_eq!(wrapped_0.len(), 40);
|
||||||
|
|
||||||
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
|
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);
|
assert_eq!(recovered_cid_0, carrier_node_id);
|
||||||
|
|
||||||
// Slot 1 Payload: 72 Bytes
|
// 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);
|
assert_eq!(wrapped_1.len(), 72);
|
||||||
|
|
||||||
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
|
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 nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
let mut ct = malicious_plaintext.clone();
|
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();
|
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
|
||||||
|
|
||||||
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
|
// 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);
|
let res = decrypt_chunk(
|
||||||
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
|
&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();
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+579
-111
File diff suppressed because it is too large
Load Diff
+206
-81
@@ -61,9 +61,7 @@ fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Resu
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
||||||
let _ = Command::new("gio")
|
let _ = Command::new("gio").args(["mount", &dav_url]).output();
|
||||||
.args(["mount", &dav_url])
|
|
||||||
.output();
|
|
||||||
let _ = (drive_letter, session_token);
|
let _ = (drive_letter, session_token);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -86,6 +84,7 @@ pub async fn mount_container(
|
|||||||
idle_timeout: Option<u64>,
|
idle_timeout: Option<u64>,
|
||||||
lock_on_screen_lock: bool,
|
lock_on_screen_lock: bool,
|
||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
|
anti_leak_list: Option<&Path>,
|
||||||
stealth: bool,
|
stealth: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let drive_str = format_drive(drive_letter);
|
let drive_str = format_drive(drive_letter);
|
||||||
@@ -106,8 +105,7 @@ pub async fn mount_container(
|
|||||||
println!();
|
println!();
|
||||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||||
}
|
}
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
|
||||||
|
|
||||||
let meta = db
|
let meta = db
|
||||||
.read_meta()
|
.read_meta()
|
||||||
@@ -116,12 +114,22 @@ pub async fn mount_container(
|
|||||||
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
|
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
|
||||||
ContainerAuth::Password(ref password) => {
|
ContainerAuth::Password(ref password) => {
|
||||||
if !stealth {
|
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) {
|
match meta.authenticate(password) {
|
||||||
Some(keys) => {
|
Some(keys) => {
|
||||||
if !stealth {
|
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 vault_id = keys.slot_id();
|
||||||
let ver = keys.version();
|
let ver = keys.version();
|
||||||
@@ -138,8 +146,7 @@ pub async fn mount_container(
|
|||||||
if !stealth {
|
if !stealth {
|
||||||
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
||||||
}
|
}
|
||||||
let dek = mnemonic_to_dek(phrase)
|
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
|
||||||
if !stealth {
|
if !stealth {
|
||||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
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!(" [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.");
|
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: ")
|
let decoy_pass = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
|
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
let decoy_keys = meta.authenticate(&decoy_pass)
|
.context("Fehler beim Einlesen des Standard-Vault Passworts")?,
|
||||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
|
);
|
||||||
|
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)
|
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
|
||||||
} else {
|
} else {
|
||||||
(None, Some(carrier_node_id), 0)
|
(None, Some(carrier_node_id), 0)
|
||||||
@@ -164,25 +174,56 @@ pub async fn mount_container(
|
|||||||
(None, None, 0)
|
(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
|
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
|
||||||
let mut token_bytes = [0u8; 16];
|
let mut token_bytes = [0u8; 16];
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||||
let session_token = hex::encode(token_bytes);
|
let session_token = hex::encode(token_bytes);
|
||||||
|
|
||||||
|
// 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)
|
// 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(),
|
db.clone(),
|
||||||
dek,
|
dek,
|
||||||
carrier_dek,
|
carrier_dek,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
version,
|
version,
|
||||||
anti_leak,
|
anti_leak,
|
||||||
|
custom_leak_rules,
|
||||||
vault_id,
|
vault_id,
|
||||||
);
|
);
|
||||||
|
let fs_leak_counter = fs.leak_counter();
|
||||||
let last_activity = fs.last_activity();
|
let last_activity = fs.last_activity();
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
@@ -340,7 +381,10 @@ pub async fn mount_container(
|
|||||||
println!();
|
println!();
|
||||||
println!(" • Container: {}", container_path.display());
|
println!(" • Container: {}", container_path.display());
|
||||||
#[cfg(windows)]
|
#[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))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
if let Some(mp) = mount_point {
|
if let Some(mp) = mount_point {
|
||||||
@@ -349,12 +393,14 @@ pub async fn mount_container(
|
|||||||
println!(" • Modus: WebDAV Userland-VFS");
|
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))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
|
for line in format_linux_mount_instructions(bound_port, &session_token, mount_point) {
|
||||||
if let Some(mp) = mount_point {
|
println!("{}", line);
|
||||||
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(secs) = idle_timeout {
|
if let Some(secs) = idle_timeout {
|
||||||
@@ -376,9 +422,15 @@ pub async fn mount_container(
|
|||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
#[cfg(windows)]
|
#[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))]
|
#[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!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +438,13 @@ pub async fn mount_container(
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||||
#[cfg(windows)]
|
#[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)
|
// Unix Signale (SIGTERM, SIGHUP)
|
||||||
#[cfg(unix)]
|
#[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)]
|
#[cfg(unix)]
|
||||||
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
|
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
|
||||||
|
|
||||||
@@ -503,7 +557,11 @@ pub async fn mount_container(
|
|||||||
} else {
|
} else {
|
||||||
#[cfg(windows)]
|
#[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());
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
|
|
||||||
// Automatisches Unmount
|
// Automatisches Unmount
|
||||||
@@ -527,7 +585,11 @@ pub async fn mount_container(
|
|||||||
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
|
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
|
||||||
if let Ok(freelist) = db.freelist_count() {
|
if let Ok(freelist) = db.freelist_count() {
|
||||||
if freelist > 0 {
|
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());
|
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||||
match db.incremental_vacuum(None) {
|
match db.incremental_vacuum(None) {
|
||||||
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
|
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
|
||||||
@@ -545,8 +607,19 @@ pub async fn mount_container(
|
|||||||
println!("{}", ui::green("OK"));
|
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!();
|
||||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
println!(
|
||||||
|
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
|
||||||
|
ui::green("✔")
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +681,36 @@ pub fn is_loopback_host(host_str: &str) -> bool {
|
|||||||
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
|
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).
|
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||||
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
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.
|
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
|
||||||
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
|
/// 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 {
|
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
|
||||||
let auth_str = auth_header.trim();
|
let auth_str = auth_header.trim();
|
||||||
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
|
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,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
||||||
pass == expected_token
|
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
|
/// 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 {
|
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 Some(val) = headers.get("X-Sanctum-Token") {
|
||||||
if let Ok(val_str) = val.to_str() {
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
|
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
|
||||||
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
|
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
|
||||||
let path = uri.path();
|
if session_token.is_empty() {
|
||||||
if !path.starts_with(prefix) {
|
return false;
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
let rest = &path[prefix.len()..];
|
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
|
||||||
let new_path = if rest.is_empty() || !rest.starts_with('/') {
|
|
||||||
format!("/{}", rest)
|
|
||||||
} else {
|
|
||||||
rest.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let path_and_query = match uri.query() {
|
|
||||||
Some(q) => format!("{}?{}", new_path, q),
|
|
||||||
None => new_path,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut parts = uri.clone().into_parts();
|
|
||||||
parts.path_and_query = Some(path_and_query.parse().ok()?);
|
|
||||||
hyper::Uri::from_parts(parts).ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
|
/// 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()
|
.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(
|
pub async fn serve_webdav_loop(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
dav_server: DavHandler,
|
dav_server: DavHandler,
|
||||||
session_token: String,
|
session_token: String,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
let conn_semaphore =
|
||||||
let token_prefix = format!("/{}", session_token);
|
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -724,15 +820,13 @@ pub async fn serve_webdav_loop(
|
|||||||
let io = TokioIo::new(stream);
|
let io = TokioIo::new(stream);
|
||||||
let handler = dav_server.clone();
|
let handler = dav_server.clone();
|
||||||
let expected_token = session_token.clone();
|
let expected_token = session_token.clone();
|
||||||
let expected_prefix = token_prefix.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||||
|
|
||||||
let service = service_fn(move |mut req| {
|
let service = service_fn(move |req| {
|
||||||
let h = handler.clone();
|
let h = handler.clone();
|
||||||
let token = expected_token.clone();
|
let token = expected_token.clone();
|
||||||
let prefix = expected_prefix.clone();
|
|
||||||
async move {
|
async move {
|
||||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||||
let host_valid = match req.headers().get(hyper::header::HOST) {
|
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()
|
let res = hyper::Response::builder()
|
||||||
.status(hyper::StatusCode::FORBIDDEN)
|
.status(hyper::StatusCode::FORBIDDEN)
|
||||||
|
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||||
.body(dav_server::body::Body::empty())
|
.body(dav_server::body::Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
return Ok::<_, Infallible>(res);
|
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 ...)
|
// a) HTTP Basic Auth (Authorization: Basic ...)
|
||||||
// b) Header X-Sanctum-Token
|
// b) Header X-Sanctum-Token
|
||||||
// c) Pfad-Präfix Fallback (/<session_token>/...)
|
|
||||||
let mut authenticated = false;
|
let mut authenticated = false;
|
||||||
|
|
||||||
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||||
@@ -773,15 +879,6 @@ pub async fn serve_webdav_loop(
|
|||||||
authenticated = true;
|
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 {
|
if !authenticated {
|
||||||
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
||||||
return Ok::<_, Infallible>(unauthorized_response());
|
return Ok::<_, Infallible>(unauthorized_response());
|
||||||
@@ -841,15 +938,24 @@ mod tests {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let token = "deadbeefcafebabe0123456789abcdef";
|
let token = "deadbeefcafebabe0123456789abcdef";
|
||||||
let creds = format!("sanctum:{}", token);
|
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));
|
assert!(check_basic_auth(&header_val, token));
|
||||||
|
|
||||||
// Kleingeschriebenes basic Präfix
|
// 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));
|
assert!(check_basic_auth(&lower_header, token));
|
||||||
|
|
||||||
// Falsches 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));
|
assert!(!check_basic_auth(&wrong_token_header, token));
|
||||||
|
|
||||||
// Ungültiges Base64 oder Format
|
// Ungültiges Base64 oder Format
|
||||||
@@ -873,22 +979,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_strip_path_prefix() {
|
fn test_uri_contains_token_rejection() {
|
||||||
let token = "deadbeefcafebabe0123456789abcdef";
|
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();
|
// Token im Pfad
|
||||||
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
|
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
|
||||||
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
|
.parse()
|
||||||
assert_eq!(stripped.query(), Some("sort=date"));
|
.unwrap();
|
||||||
|
assert!(uri_contains_token(&uri_path, token));
|
||||||
|
|
||||||
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
|
// Token im Query-String
|
||||||
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
|
let uri_query: hyper::Uri =
|
||||||
assert_eq!(stripped_root.path(), "/");
|
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();
|
// Harmloser Pfad ohne Token
|
||||||
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
|
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
@@ -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,
|
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
||||||
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
/// 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.");
|
bail!("Dateiname darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
if name == "." || name == ".." {
|
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') {
|
if name.contains('/') || name.contains('\\') || name.contains('\0') {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -29,9 +42,8 @@ pub fn validate_node_name(name: &str) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let base_upper = base_name.to_ascii_uppercase();
|
let base_upper = base_name.to_ascii_uppercase();
|
||||||
let reserved = [
|
let reserved = [
|
||||||
"CON", "PRN", "AUX", "NUL",
|
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
||||||
];
|
];
|
||||||
if reserved.contains(&base_upper.as_str()) {
|
if reserved.contains(&base_upper.as_str()) {
|
||||||
bail!(
|
bail!(
|
||||||
|
|||||||
+180
-32
@@ -85,11 +85,31 @@ impl HeaderBackup {
|
|||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
|
|
||||||
if !self.slots.is_empty() {
|
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 {
|
for s in &self.slots {
|
||||||
let salt_bytes = hex::decode(&s.kdf_salt_hex)
|
if s.slot_id > 1 {
|
||||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
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 {
|
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];
|
let mut kdf_salt = [0u8; 16];
|
||||||
kdf_salt.copy_from_slice(&salt_bytes);
|
kdf_salt.copy_from_slice(&salt_bytes);
|
||||||
@@ -97,10 +117,24 @@ impl HeaderBackup {
|
|||||||
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
|
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
|
||||||
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
|
.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)
|
let nonce_bytes = hex::decode(&s.header_nonce_hex)
|
||||||
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
||||||
if nonce_bytes.len() != 12 {
|
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];
|
let mut header_nonce = [0u8; 12];
|
||||||
header_nonce.copy_from_slice(&nonce_bytes);
|
header_nonce.copy_from_slice(&nonce_bytes);
|
||||||
@@ -108,7 +142,10 @@ impl HeaderBackup {
|
|||||||
let tag_bytes = hex::decode(&s.header_tag_hex)
|
let tag_bytes = hex::decode(&s.header_tag_hex)
|
||||||
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
||||||
if tag_bytes.len() != 16 {
|
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];
|
let mut header_tag = [0u8; 16];
|
||||||
header_tag.copy_from_slice(&tag_bytes);
|
header_tag.copy_from_slice(&tag_bytes);
|
||||||
@@ -123,12 +160,19 @@ impl HeaderBackup {
|
|||||||
header_tag,
|
header_tag,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !seen_ids.contains(&0) {
|
||||||
|
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback für alte Backups ohne slots-Array
|
// Fallback für alte Backups ohne slots-Array
|
||||||
let salt_bytes = hex::decode(&self.kdf_salt_hex)
|
let salt_bytes =
|
||||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
hex::decode(&self.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||||
if salt_bytes.len() != 16 {
|
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];
|
let mut kdf_salt = [0u8; 16];
|
||||||
kdf_salt.copy_from_slice(&salt_bytes);
|
kdf_salt.copy_from_slice(&salt_bytes);
|
||||||
@@ -139,7 +183,10 @@ impl HeaderBackup {
|
|||||||
let nonce_bytes = hex::decode(&self.header_nonce_hex)
|
let nonce_bytes = hex::decode(&self.header_nonce_hex)
|
||||||
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
|
||||||
if nonce_bytes.len() != 12 {
|
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];
|
let mut header_nonce = [0u8; 12];
|
||||||
header_nonce.copy_from_slice(&nonce_bytes);
|
header_nonce.copy_from_slice(&nonce_bytes);
|
||||||
@@ -147,7 +194,10 @@ impl HeaderBackup {
|
|||||||
let tag_bytes = hex::decode(&self.header_tag_hex)
|
let tag_bytes = hex::decode(&self.header_tag_hex)
|
||||||
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
.context("Ungültige Hex-Kodierung für Header-Tag")?;
|
||||||
if tag_bytes.len() != 16 {
|
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];
|
let mut header_tag = [0u8; 16];
|
||||||
header_tag.copy_from_slice(&tag_bytes);
|
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).
|
/// 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<()> {
|
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
|
.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 backup = HeaderBackup::from_meta(&meta);
|
||||||
let json_data = serde_json::to_string_pretty(&backup)
|
let json_data = serde_json::to_string_pretty(&backup)
|
||||||
.context("Fehler beim Serialisieren des Header-Backups")?;
|
.context("Fehler beim Serialisieren des Header-Backups")?;
|
||||||
|
|
||||||
fs::write(backup_path, json_data)
|
fs::write(backup_path, json_data).with_context(|| {
|
||||||
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
|
format!(
|
||||||
|
"Konnte Backup-Datei '{}' nicht schreiben",
|
||||||
|
backup_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
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());
|
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(backup_path)
|
let content = fs::read_to_string(backup_path).with_context(|| {
|
||||||
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
|
format!(
|
||||||
|
"Konnte Backup-Datei '{}' nicht lesen",
|
||||||
|
backup_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let backup: HeaderBackup = serde_json::from_str(&content)
|
let backup: HeaderBackup = serde_json::from_str(&content)
|
||||||
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
|
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
|
||||||
|
|
||||||
let meta = backup.to_meta()?;
|
let meta = backup.to_meta()?;
|
||||||
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
|
||||||
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
|
|
||||||
|
|
||||||
db.restore_meta(&meta)
|
db.restore_meta(&meta)
|
||||||
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -234,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) ->
|
|||||||
&chunk0.nonce,
|
&chunk0.nonce,
|
||||||
&chunk0.tag,
|
&chunk0.tag,
|
||||||
version,
|
version,
|
||||||
|
chunk0.generation,
|
||||||
)
|
)
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if is_dek0 {
|
if is_dek0 {
|
||||||
@@ -256,14 +320,16 @@ pub fn restore_slot_from_recovery_key(
|
|||||||
dek_0: Option<&[u8; 32]>,
|
dek_0: Option<&[u8; 32]>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_password(new_password)?;
|
validate_password(new_password)?;
|
||||||
|
|
||||||
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
||||||
let dek = mnemonic_to_dek(recovery_key)
|
let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
|
||||||
|
|
||||||
// 2. Neuen KEK mit frischem Salt ableiten
|
// 2. Neuen KEK mit frischem Salt ableiten
|
||||||
let mut salt = [0u8; 16];
|
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)
|
let kek = derive_kek(new_password, &salt, &kdf_params)
|
||||||
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
|
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
|
||||||
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
|
||||||
|
|
||||||
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
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)
|
db.restore_meta(&meta)
|
||||||
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
.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(())
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -370,8 +503,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_header_backup_and_restore() {
|
fn test_header_backup_and_restore() {
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
|
let container_path: PathBuf =
|
||||||
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
|
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() {
|
if container_path.exists() {
|
||||||
let _ = fs::remove_file(&container_path);
|
let _ = fs::remove_file(&container_path);
|
||||||
@@ -392,7 +527,8 @@ mod tests {
|
|||||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).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();
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
// 1. Export
|
// 1. Export
|
||||||
@@ -416,7 +552,12 @@ mod tests {
|
|||||||
let restored_db = Database::open(&container_path).unwrap();
|
let restored_db = Database::open(&container_path).unwrap();
|
||||||
let meta = restored_db.read_meta().expect("Read restored meta");
|
let meta = restored_db.read_meta().expect("Read restored meta");
|
||||||
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||||
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
|
let active_dek = unwrap_dek(
|
||||||
|
&restored_kek,
|
||||||
|
&meta.wrapped_dek,
|
||||||
|
&meta.header_nonce,
|
||||||
|
&meta.header_tag,
|
||||||
|
)
|
||||||
.expect("Unwrap restored DEK");
|
.expect("Unwrap restored DEK");
|
||||||
assert_eq!(*dek, *active_dek);
|
assert_eq!(*dek, *active_dek);
|
||||||
|
|
||||||
@@ -427,7 +568,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_restore_from_recovery_key() {
|
fn test_restore_from_recovery_key() {
|
||||||
let temp_dir = std::env::temp_dir();
|
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() {
|
if container_path.exists() {
|
||||||
let _ = fs::remove_file(&container_path);
|
let _ = fs::remove_file(&container_path);
|
||||||
@@ -450,7 +592,8 @@ mod tests {
|
|||||||
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
|
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).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();
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
// Header zerstören
|
// Header zerstören
|
||||||
@@ -466,7 +609,12 @@ mod tests {
|
|||||||
let rescued_db = Database::open(&container_path).unwrap();
|
let rescued_db = Database::open(&container_path).unwrap();
|
||||||
let meta = rescued_db.read_meta().expect("Read rescued meta");
|
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 new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||||
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
|
let unwrapped = unwrap_dek(
|
||||||
|
&new_kek,
|
||||||
|
&meta.wrapped_dek,
|
||||||
|
&meta.header_nonce,
|
||||||
|
&meta.header_tag,
|
||||||
|
)
|
||||||
.expect("Unwrap rescued DEK");
|
.expect("Unwrap rescued DEK");
|
||||||
assert_eq!(*dek, *unwrapped);
|
assert_eq!(*dek, *unwrapped);
|
||||||
|
|
||||||
|
|||||||
+1169
-221
File diff suppressed because it is too large
Load Diff
+641
-84
File diff suppressed because it is too large
Load Diff
@@ -131,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
|||||||
pub fn print_recovery_phrase_card(phrase: &str) {
|
pub fn print_recovery_phrase_card(phrase: &str) {
|
||||||
let words: Vec<&str> = phrase.split_whitespace().collect();
|
let words: Vec<&str> = phrase.split_whitespace().collect();
|
||||||
println!();
|
println!();
|
||||||
println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐"));
|
println!(
|
||||||
println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │"));
|
"{}",
|
||||||
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
|
yellow("┌─────────────────────────────────────────────────────────────┐")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
yellow("├─────────────────────────────────────────────────────────────┤")
|
||||||
|
);
|
||||||
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
|
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
|
||||||
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
|
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
|
||||||
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
|
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
|
||||||
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
|
println!(
|
||||||
|
"{}",
|
||||||
|
yellow("├─────────────────────────────────────────────────────────────┤")
|
||||||
|
);
|
||||||
|
|
||||||
for row in 0..8 {
|
for row in 0..8 {
|
||||||
let w1 = if row < words.len() {
|
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!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
|
println!(
|
||||||
|
"{}",
|
||||||
|
yellow("└─────────────────────────────────────────────────────────────┘")
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
|||||||
println!(" - Daten-Chunks: {}", report.total_chunks);
|
println!(" - Daten-Chunks: {}", report.total_chunks);
|
||||||
if report.total_bytes_decrypted > 0 {
|
if report.total_bytes_decrypted > 0 {
|
||||||
let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.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() {
|
if !report.errors.is_empty() {
|
||||||
@@ -217,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
if report.is_healthy() {
|
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 {
|
} 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!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-24
@@ -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_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||||
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||||
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
|
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
|
/// Repräsentiert ein Asset in einem Gitea-Release
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[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).
|
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (U-01).
|
||||||
pub fn verify_minisign_signature(
|
///
|
||||||
data: &[u8],
|
/// Erzwingt strikt Pre-Hashing (`allow_legacy = false`), um Malleability- und Längenangriffe auszuschließen.
|
||||||
sig_str: &str,
|
/// Gibt bei erfolgreicher Verifikation den kryptografisch authentifizierten `trusted comment` zurück.
|
||||||
pubkey_str: &str,
|
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<String> {
|
||||||
) -> Result<()> {
|
|
||||||
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
||||||
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
||||||
let signature = minisign_verify::Signature::decode(sig_str)
|
let signature = minisign_verify::Signature::decode(sig_str)
|
||||||
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
|
||||||
pubkey
|
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!"))?;
|
.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(())
|
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
|
/// Erkannter Paketmanager
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PackageManager {
|
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 api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
|
||||||
|
|
||||||
let resp = ureq::get(&api_url)
|
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")
|
.set("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(15))
|
.timeout(Duration::from_secs(15))
|
||||||
.call()
|
.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.
|
/// Lädt eine Datei von einer URL als Text herunter.
|
||||||
pub fn fetch_text(url: &str) -> Result<String> {
|
pub fn fetch_text(url: &str) -> Result<String> {
|
||||||
let resp = ureq::get(url)
|
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))
|
.timeout(Duration::from_secs(15))
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
|
.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.
|
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
|
||||||
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
|
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))
|
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,
|
expected_size: u64,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
let resp = ureq::get(download_url)
|
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))
|
.timeout(Duration::from_secs(120))
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
|
.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
|
let (_, path) = temp_file
|
||||||
.keep()
|
.keep()
|
||||||
@@ -324,12 +400,16 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
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!(" • Server: {}", options.base_url);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// S-02: Prüfung auf abweichende Server-URL
|
// 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 !is_official_server {
|
||||||
if !options.insecure_url {
|
if !options.insecure_url {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -389,12 +469,22 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Neuestes Release abfragen
|
// 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 release = fetch_latest_release(&options.base_url)?;
|
||||||
let remote_tag = &release.tag_name;
|
let remote_tag = &release.tag_name;
|
||||||
let is_newer = is_newer_version(current_version, remote_tag);
|
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);
|
println!(" • Release-Name: {}", release.name);
|
||||||
if let Some(ref pub_at) = release.published_at {
|
if let Some(ref pub_at) = release.published_at {
|
||||||
println!(" • Veröffentlicht: {}", pub_at);
|
println!(" • Veröffentlicht: {}", pub_at);
|
||||||
@@ -459,18 +549,34 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
|
// 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)?;
|
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)?;
|
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
|
||||||
|
|
||||||
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
|
println!(
|
||||||
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
|
" {} 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.")?;
|
.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(|| {
|
let expected_hash =
|
||||||
|
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||||
TARGET_ASSET_NAME
|
TARGET_ASSET_NAME
|
||||||
@@ -479,7 +585,11 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
|
|
||||||
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
||||||
if !options.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!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
|
||||||
println!();
|
println!();
|
||||||
print!(
|
print!(
|
||||||
@@ -513,7 +623,10 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
|
println!(
|
||||||
|
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
|
||||||
|
remote_tag
|
||||||
|
);
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!(" • Pfad: {}", updated_exe.display());
|
println!(" • Pfad: {}", updated_exe.display());
|
||||||
println!(" • Neue Version: {}", ui::green(remote_tag));
|
println!(" • Neue Version: {}", ui::green(remote_tag));
|
||||||
|
|||||||
+99
-29
@@ -4,7 +4,7 @@ use std::path::Path;
|
|||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use zeroize::Zeroizing;
|
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;
|
use crate::storage::Database;
|
||||||
|
|
||||||
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
|
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
|
||||||
@@ -43,11 +43,13 @@ pub fn verify_container(
|
|||||||
full_chunks: bool,
|
full_chunks: bool,
|
||||||
) -> Result<VerificationReport> {
|
) -> Result<VerificationReport> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
|
||||||
|
|
||||||
let mut report = VerificationReport {
|
let mut report = VerificationReport {
|
||||||
container_path: container_path.display().to_string(),
|
container_path: container_path.display().to_string(),
|
||||||
@@ -67,7 +69,8 @@ pub fn verify_container(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. SQLite B-Tree & Foreign Key Prüfung
|
// 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")?;
|
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
|
||||||
if !sqlite_issues.is_empty() {
|
if !sqlite_issues.is_empty() {
|
||||||
report.sqlite_ok = false;
|
report.sqlite_ok = false;
|
||||||
@@ -78,7 +81,10 @@ pub fn verify_container(
|
|||||||
let meta = match db.read_meta() {
|
let meta = match db.read_meta() {
|
||||||
Ok(m) => {
|
Ok(m) => {
|
||||||
report.format_version = m.version;
|
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_ok = false;
|
||||||
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
|
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
|
||||||
}
|
}
|
||||||
@@ -96,13 +102,16 @@ pub fn verify_container(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. Node-Hierarchie & Strukturprüfung
|
// 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")?;
|
.context("Fehler beim Zählen der Knoten und Chunks")?;
|
||||||
report.total_dirs = dirs;
|
report.total_dirs = dirs;
|
||||||
report.total_files = files;
|
report.total_files = files;
|
||||||
report.total_chunks = chunks_count;
|
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();
|
report.total_nodes = all_nodes.len();
|
||||||
|
|
||||||
let mut node_map = HashMap::new();
|
let mut node_map = HashMap::new();
|
||||||
@@ -114,23 +123,33 @@ pub fn verify_container(
|
|||||||
match node_map.get(&1) {
|
match node_map.get(&1) {
|
||||||
Some(root) => {
|
Some(root) => {
|
||||||
if !root.is_dir {
|
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() {
|
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 => {
|
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 let Some(root2) = node_map.get(&2) {
|
||||||
if !root2.is_dir {
|
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() {
|
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
|
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
|
// 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")?;
|
.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 {
|
for (node_id, chunk_index) in chunk_headers {
|
||||||
if !node_map.contains_key(&node_id) {
|
if !node_map.contains_key(&node_id) {
|
||||||
@@ -257,6 +301,7 @@ pub fn verify_container(
|
|||||||
&record.nonce,
|
&record.nonce,
|
||||||
&record.tag,
|
&record.tag,
|
||||||
format_version,
|
format_version,
|
||||||
|
record.generation,
|
||||||
) {
|
) {
|
||||||
Ok(plaintext) => {
|
Ok(plaintext) => {
|
||||||
report.total_bytes_decrypted += plaintext.len() as u64;
|
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 (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).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
|
// Verzeichnis & Datei anlegen
|
||||||
let folder = db.create_node(1, "photos", true).unwrap();
|
let folder = db.create_node(1, "photos", true).unwrap();
|
||||||
@@ -327,19 +374,30 @@ mod tests {
|
|||||||
|
|
||||||
// 2 Chunks schreiben
|
// 2 Chunks schreiben
|
||||||
let chunk0_data = b"Sample JPEG data header and pixels";
|
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();
|
let (ct0, n0, t0) =
|
||||||
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
|
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 chunk1_data = b"Additional payload data bytes";
|
||||||
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
|
let (ct1, n1, t1) =
|
||||||
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
|
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();
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
// Verifizieren
|
// Verifizieren
|
||||||
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
||||||
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
|
assert!(
|
||||||
|
report.is_healthy(),
|
||||||
|
"Container must be healthy, report: {:?}",
|
||||||
|
report
|
||||||
|
);
|
||||||
assert_eq!(report.total_files, 1);
|
assert_eq!(report.total_files, 1);
|
||||||
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
|
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
|
||||||
assert_eq!(report.total_chunks, 2);
|
assert_eq!(report.total_chunks, 2);
|
||||||
@@ -371,12 +429,14 @@ mod tests {
|
|||||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).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 file = db.create_node(1, "document.pdf", false).unwrap();
|
||||||
let chunk_data = b"Vital documents that must not be corrupted";
|
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();
|
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap();
|
||||||
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
|
db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
|
||||||
db.checkpoint().unwrap();
|
db.checkpoint().unwrap();
|
||||||
drop(db);
|
drop(db);
|
||||||
|
|
||||||
@@ -387,14 +447,24 @@ mod tests {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||||
rusqlite::params![corrupted_ct, file.id],
|
rusqlite::params![corrupted_ct, file.id],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
|
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
|
||||||
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
||||||
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
|
assert!(
|
||||||
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
|
!report.is_healthy(),
|
||||||
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
|
"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);
|
let _ = fs::remove_file(&container_path);
|
||||||
}
|
}
|
||||||
|
|||||||
+713
-113
File diff suppressed because it is too large
Load Diff
+129
-46
@@ -44,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
|||||||
Command::new("explorer.exe")
|
Command::new("explorer.exe")
|
||||||
.arg(&drive_path)
|
.arg(&drive_path)
|
||||||
.spawn()
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -73,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
|||||||
Command::new("explorer.exe")
|
Command::new("explorer.exe")
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.spawn()
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
Command::new("open")
|
Command::new("open").arg(path).spawn().with_context(|| {
|
||||||
.arg(path)
|
format!("Konnte macOS Finder für '{}' nicht öffnen", path.display())
|
||||||
.spawn()
|
})?;
|
||||||
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[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")
|
Command::new("xdg-open")
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.spawn()
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(not(any(windows, unix)))]
|
#[cfg(not(any(windows, unix)))]
|
||||||
@@ -129,8 +140,7 @@ pub fn notify_shell_associations_changed() {
|
|||||||
let _ = Command::new("update-desktop-database").spawn();
|
let _ = Command::new("update-desktop-database").spawn();
|
||||||
}
|
}
|
||||||
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
|
#[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.
|
/// 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 = [
|
let reg_commands = [
|
||||||
// 1. .sanctum Erweiterung mit ProgID verknüpfen
|
// 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
|
// 2. ProgID Metadaten & Beschreibung
|
||||||
(
|
(
|
||||||
r"HKCU\Software\Classes\Sanctum.Container",
|
r"HKCU\Software\Classes\Sanctum.Container",
|
||||||
@@ -205,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> {
|
|||||||
}
|
}
|
||||||
cmd.arg("/d").arg(val_data).arg("/f");
|
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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
bail!("Registry-Fehler beim Anlegen von {key}: {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 {
|
for key in keys_to_delete {
|
||||||
let _ = Command::new("reg")
|
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
|
||||||
.args(["delete", key, "/f"])
|
|
||||||
.output();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notify_shell_associations_changed();
|
notify_shell_associations_changed();
|
||||||
@@ -258,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
|||||||
{
|
{
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
let home_path = std::path::PathBuf::from(home);
|
let home_path = std::path::PathBuf::from(home);
|
||||||
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
|
let _ =
|
||||||
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
|
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();
|
notify_shell_associations_changed();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -496,9 +505,9 @@ pub fn start_session_lock_monitor(
|
|||||||
})
|
})
|
||||||
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
|
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
|
||||||
|
|
||||||
let hwnd = hwnd_rx
|
let hwnd = hwnd_rx.recv().map_err(|e| {
|
||||||
.recv()
|
anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}")
|
||||||
.map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?;
|
})?;
|
||||||
|
|
||||||
if hwnd == 0 {
|
if hwnd == 0 {
|
||||||
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
|
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
|
||||||
@@ -522,7 +531,8 @@ pub fn start_session_lock_monitor(
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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)]
|
#[cfg(windows)]
|
||||||
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
@@ -550,11 +560,7 @@ extern "system" {
|
|||||||
dwFlags: u32,
|
dwFlags: u32,
|
||||||
) -> u32;
|
) -> u32;
|
||||||
|
|
||||||
fn WNetCancelConnection2W(
|
fn WNetCancelConnection2W(lpName: *const u16, dwFlags: u32, fForce: i32) -> u32;
|
||||||
lpName: *const u16,
|
|
||||||
dwFlags: u32,
|
|
||||||
fForce: i32,
|
|
||||||
) -> u32;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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 Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||||
if let Some(dl) = *guard {
|
if let Some(dl) = *guard {
|
||||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||||
let wide_drive: Vec<u16> = drive_str
|
let wide_drive: Vec<u16> =
|
||||||
.encode_utf16()
|
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
.chain(std::iter::once(0))
|
|
||||||
.collect();
|
|
||||||
unsafe {
|
unsafe {
|
||||||
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
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 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 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 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 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 {
|
let nr = NETRESOURCEW {
|
||||||
dwScope: 0,
|
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)
|
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
|
||||||
if res == 85 || res == 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 {
|
unsafe {
|
||||||
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
|
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
|
||||||
}
|
}
|
||||||
res = unsafe {
|
res = unsafe { WNetAddConnection2W(&nr, password.as_ptr(), username.as_ptr(), 0) };
|
||||||
WNetAddConnection2W(
|
|
||||||
&nr,
|
|
||||||
password.as_ptr(),
|
|
||||||
username.as_ptr(),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if res != 0 {
|
if res != 0 {
|
||||||
@@ -813,6 +819,84 @@ pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -829,7 +913,7 @@ mod tests {
|
|||||||
fn test_find_next_available_drive() {
|
fn test_find_next_available_drive() {
|
||||||
let drive = find_next_available_drive().expect("Find next drive");
|
let drive = find_next_available_drive().expect("Find next drive");
|
||||||
assert!(drive.is_ascii_alphabetic());
|
assert!(drive.is_ascii_alphabetic());
|
||||||
assert!(drive >= 'D' && drive <= 'Z');
|
assert!(('D'..='Z').contains(&drive));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -849,4 +933,3 @@ mod tests {
|
|||||||
drop(monitor);
|
drop(monitor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+488
-35
@@ -7,8 +7,8 @@ use rand::rngs::OsRng;
|
|||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
||||||
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::verify::verify_container;
|
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 dek_1 = generate_dek();
|
||||||
|
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_0, nonce_0, tag_0) =
|
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
|
||||||
let (wrapped_1, nonce_1, tag_1) =
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
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();
|
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
|
||||||
|
|
||||||
// Metadaten der Alibi-Datei im Decoy prüfen
|
// 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_eq!(carrier_meta.len(), carrier_size_bytes);
|
||||||
assert!(!carrier_meta.is_dir());
|
assert!(!carrier_meta.is_dir());
|
||||||
|
|
||||||
@@ -113,20 +115,29 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(
|
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 nicht zum Schreiben geöffnet werden"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
|
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
|
||||||
assert!(
|
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 nicht gelöscht werden"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
|
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
|
||||||
let new_name = DavPath::new("/renamed.iso").unwrap();
|
let new_name = DavPath::new("/renamed.iso").unwrap();
|
||||||
assert!(
|
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"
|
"Alibi-Datei darf nicht umbenannt werden"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,8 +146,14 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
|||||||
read: true,
|
read: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
|
let mut file_reader = decoy_fs
|
||||||
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
|
.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);
|
assert_eq!(first_mb.len(), CHUNK_SIZE);
|
||||||
|
|
||||||
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
|
// 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 {
|
while let Some(item) = stream.next().await {
|
||||||
entries.push(item.unwrap().name());
|
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
|
// Ordner erstellen
|
||||||
let secret_dir = DavPath::new("/Classified").unwrap();
|
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
|
// Datei im Ordner anlegen und schreiben
|
||||||
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
|
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)
|
.open(&secret_file_path, read_opts)
|
||||||
.await
|
.await
|
||||||
.expect("Open secret file for read");
|
.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);
|
assert_eq!(&read_data[..], secret_content);
|
||||||
drop(read_handle);
|
drop(read_handle);
|
||||||
|
|
||||||
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
|
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
|
||||||
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
|
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
|
||||||
let mut big_file = hidden_fs
|
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
|
.await
|
||||||
.expect("Create big file");
|
.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];
|
let mut payload = vec![0u8; payload_size];
|
||||||
OsRng.fill_bytes(&mut payload);
|
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");
|
big_file.flush().await.expect("Flush big file");
|
||||||
drop(big_file);
|
drop(big_file);
|
||||||
|
|
||||||
// Datei zurücklesen und Bit-für-Bit verifizieren
|
// Datei zurücklesen und Bit-für-Bit verifizieren
|
||||||
let mut read_big = hidden_fs
|
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
|
.await
|
||||||
.expect("Open big file");
|
.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.len(), payload_size);
|
||||||
assert_eq!(&read_big_bytes[..], &payload[..]);
|
assert_eq!(&read_big_bytes[..], &payload[..]);
|
||||||
drop(read_big);
|
drop(read_big);
|
||||||
|
|
||||||
// Datei umbenennen
|
// Datei umbenennen
|
||||||
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
|
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(&big_file_path).await.is_err());
|
||||||
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
|
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
|
||||||
|
|
||||||
// Datei löschen (Blöcke werden geshreddert und freigegeben)
|
// 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());
|
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
|
||||||
|
|
||||||
// Checkpoint SQLite
|
// Checkpoint SQLite
|
||||||
@@ -284,8 +335,7 @@ async fn test_model_a_container_file_size_invariance() {
|
|||||||
let dek_1 = generate_dek();
|
let dek_1 = generate_dek();
|
||||||
|
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_0, nonce_0, tag_0) =
|
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
|
||||||
let (wrapped_1, nonce_1, tag_1) =
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
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
|
// Initiale Dateigröße messen
|
||||||
let initial_file_size = std::fs::metadata(&path).unwrap().len();
|
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
|
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
|
||||||
let meta = db.read_meta().unwrap();
|
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 test_file = DavPath::new("/large_confidential.pdf").unwrap();
|
||||||
let mut handle = hidden_fs
|
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
|
.await
|
||||||
.expect("Open file");
|
.expect("Open file");
|
||||||
|
|
||||||
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
|
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
|
||||||
OsRng.fill_bytes(&mut random_data);
|
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");
|
handle.flush().await.expect("Flush 4MB");
|
||||||
drop(handle);
|
drop(handle);
|
||||||
|
|
||||||
@@ -383,8 +446,7 @@ async fn test_carrier_file_drop_and_append_mode() {
|
|||||||
let dek_1 = generate_dek();
|
let dek_1 = generate_dek();
|
||||||
|
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_0, nonce_0, tag_0) =
|
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
|
||||||
let (wrapped_1, nonce_1, tag_1) =
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
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)
|
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
|
||||||
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
||||||
let mut write_handle = hidden_fs
|
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
|
.await
|
||||||
.expect("Open file for write");
|
.expect("Open file for write");
|
||||||
|
|
||||||
let initial_data = b"Hello from unflushed 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:
|
// WICHTIG: KEIN write_handle.flush()! Nur drop:
|
||||||
drop(write_handle);
|
drop(write_handle);
|
||||||
|
|
||||||
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
||||||
let mut read_handle = hidden_fs
|
let mut read_handle = hidden_fs
|
||||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
.open(
|
||||||
|
&test_file,
|
||||||
|
OpenOptions {
|
||||||
|
read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Open file for read");
|
.expect("Open file for read");
|
||||||
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
|
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);
|
drop(read_handle);
|
||||||
|
|
||||||
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
||||||
let append_data = b" - Appended data at EOF!";
|
let append_data = b" - Appended data at EOF!";
|
||||||
let mut append_handle = hidden_fs
|
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
|
.await
|
||||||
.expect("Open file for append");
|
.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
|
drop(append_handle); // Drop sichert auch hier
|
||||||
|
|
||||||
// Prüfe den vollständigen Dateiinhalt nach Append
|
// Prüfe den vollständigen Dateiinhalt nach Append
|
||||||
let mut read_handle_2 = hidden_fs
|
let mut read_handle_2 = hidden_fs
|
||||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
.open(
|
||||||
|
&test_file,
|
||||||
|
OpenOptions {
|
||||||
|
read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Open file for read after append");
|
.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();
|
let mut expected = Vec::new();
|
||||||
expected.extend_from_slice(initial_data);
|
expected.extend_from_slice(initial_data);
|
||||||
expected.extend_from_slice(append_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);
|
drop(read_handle_2);
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
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!(©_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!(©_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);
|
||||||
|
}
|
||||||
|
|||||||
+747
-130
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ use sanctum::{
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_live_crash_and_recovery_stress() {
|
async fn test_live_crash_and_recovery_stress() {
|
||||||
let temp_dir = std::env::temp_dir();
|
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() {
|
if container_path.exists() {
|
||||||
let _ = std::fs::remove_file(&container_path);
|
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
|
// 4. Recovery & Integritätsprüfung nach Crash
|
||||||
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
|
// 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!(
|
assert!(
|
||||||
verify_result.is_healthy(),
|
verify_result.is_healthy(),
|
||||||
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
|
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
|
||||||
verify_result.errors
|
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
|
// 5. Konsistentes Weiterarbeiten nach dem Absturz
|
||||||
let db_recovered = Database::open(&container_path).expect("Open database after crash");
|
let db_recovered = Database::open(&container_path).expect("Open database after crash");
|
||||||
|
|||||||
+211
-15
@@ -1,10 +1,10 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||||
MIN_TIME_COST,
|
MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::mount::is_loopback_host;
|
use sanctum::mount::is_loopback_host;
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_loopback_host_comprehensive() {
|
fn test_is_loopback_host_comprehensive() {
|
||||||
@@ -45,7 +45,8 @@ fn test_is_loopback_host_comprehensive() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_wal_and_shm_cleanup_on_close() {
|
fn test_wal_and_shm_cleanup_on_close() {
|
||||||
let temp_dir = std::env::temp_dir();
|
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() {
|
if container_path.exists() {
|
||||||
let _ = std::fs::remove_file(&container_path);
|
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 (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&container_path).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
|
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
|
||||||
let _ = db.create_node(1, "test_file.txt", false).unwrap();
|
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");
|
shm.push("-shm");
|
||||||
let _ = std::fs::remove_file(&shm);
|
let _ = std::fs::remove_file(&shm);
|
||||||
|
|
||||||
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
|
assert!(
|
||||||
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
|
!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);
|
let _ = std::fs::remove_file(&container_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mount_security_multi_auth_and_no_token_in_url() {
|
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 session_token = "4f8a12bc90de45f187a23456789abcde";
|
||||||
let port = 8443;
|
let port = 8443;
|
||||||
let remote_url = format!("http://127.0.0.1:{}/", port);
|
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!
|
// 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");
|
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;
|
use base64::Engine;
|
||||||
let auth_header = format!(
|
let auth_header = format!(
|
||||||
"Basic {}",
|
"Basic {}",
|
||||||
@@ -102,14 +113,199 @@ fn test_mount_security_multi_auth_and_no_token_in_url() {
|
|||||||
);
|
);
|
||||||
assert!(check_basic_auth(&auth_header, session_token));
|
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();
|
let mut headers = hyper::HeaderMap::new();
|
||||||
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
||||||
assert!(check_token_header(&headers, session_token));
|
assert!(check_token_header(&headers, session_token));
|
||||||
|
|
||||||
// 3. Path-Prefix Fallback
|
let mut wrong_headers = hyper::HeaderMap::new();
|
||||||
let prefix = format!("/{}", session_token);
|
wrong_headers.insert(
|
||||||
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
|
"X-Sanctum-Token",
|
||||||
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
|
"wrong_token_1234567890abcdef".parse().unwrap(),
|
||||||
assert_eq!(stripped.path(), "/test.txt");
|
);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,25 @@ fn test_validate_node_name_rejections() {
|
|||||||
|
|
||||||
// 6. Windows reservierte Gerätenamen
|
// 6. Windows reservierte Gerätenamen
|
||||||
let reserved_names = [
|
let reserved_names = [
|
||||||
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
|
"CON",
|
||||||
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
|
"con",
|
||||||
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
|
"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 {
|
for res in reserved_names {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -64,7 +80,10 @@ fn test_validate_node_name_accepted() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_storage_create_and_rename_reject_invalid_names() {
|
fn test_storage_create_and_rename_reject_invalid_names() {
|
||||||
let mut path = std::env::temp_dir();
|
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() {
|
if path.exists() {
|
||||||
let _ = std::fs::remove_file(&path);
|
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 (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = sanctum::storage::Database::open(&path).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
|
// 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, "..", 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, "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
|
// 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
|
// 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
|
||||||
assert!(db.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek).is_err());
|
.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek)
|
||||||
assert!(db.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek).is_err());
|
.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);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
||||||
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
|
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 dek_1 = generate_dek();
|
||||||
|
|
||||||
let carrier_node_id = 3i64;
|
let carrier_node_id = 3i64;
|
||||||
let (wrapped_0, nonce_0, tag_0) =
|
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
|
||||||
let (wrapped_1, nonce_1, tag_1) =
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
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
|
// 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
|
||||||
let del_res = db.delete_node(carrier_node_id);
|
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"));
|
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
||||||
|
|
||||||
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
|
// 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);
|
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!(
|
||||||
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
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
|
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
|
||||||
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
|
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
|
||||||
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
|
assert!(
|
||||||
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
|
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:
|
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
|
||||||
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
||||||
let local_paths = HashSet::new();
|
let local_paths = HashSet::new();
|
||||||
|
let excluded_paths = HashSet::new();
|
||||||
let mut stats = SyncStats::default();
|
let mut stats = SyncStats::default();
|
||||||
let orphan_res = delete_orphans_in_vault(
|
let orphan_res = delete_orphans_in_vault(
|
||||||
&db,
|
&db,
|
||||||
@@ -98,20 +113,33 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
1, // Root-Knoten
|
1, // Root-Knoten
|
||||||
"",
|
"",
|
||||||
&local_paths,
|
&local_paths,
|
||||||
|
&excluded_paths,
|
||||||
|
&[],
|
||||||
|
false, // delete_excluded: false
|
||||||
false, // kein dry_run
|
false, // kein dry_run
|
||||||
true, // quiet
|
true, // quiet
|
||||||
&mut stats,
|
&mut stats,
|
||||||
);
|
);
|
||||||
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
|
assert!(
|
||||||
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
|
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
|
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
|
||||||
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
|
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
|
||||||
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
|
assert!(
|
||||||
|
carrier_rec.is_some(),
|
||||||
|
"Carrier-Knoten muss nach Sync unversehrt vorhanden sein"
|
||||||
|
);
|
||||||
|
|
||||||
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
|
// 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 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(
|
let pull_single_res = sanctum::sync::sync_single_file_to_host(
|
||||||
&db,
|
&db,
|
||||||
0,
|
0,
|
||||||
@@ -122,18 +150,29 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert!(pull_single_res.is_err(), "sync_single_file_to_host auf Carrier muss fehlschlagen");
|
assert!(
|
||||||
assert!(!local_dest_file.exists(), "Trägerdatei darf niemals auf den Host geschrieben werden");
|
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
|
// 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 {
|
let sync_options = sanctum::sync::SyncOptions {
|
||||||
direction: sanctum::sync::SyncDirection::Pull,
|
direction: sanctum::sync::SyncDirection::Pull,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
delete_excluded: false,
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: true,
|
quiet: true,
|
||||||
|
force: false,
|
||||||
|
backup: false,
|
||||||
|
update: false,
|
||||||
};
|
};
|
||||||
let pull_res = sanctum::sync::run_sync(
|
let pull_res = sanctum::sync::run_sync(
|
||||||
&db,
|
&db,
|
||||||
@@ -144,9 +183,15 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
&local_pull_dir.to_string_lossy(),
|
&local_pull_dir.to_string_lossy(),
|
||||||
&sync_options,
|
&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);
|
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);
|
let _ = std::fs::remove_dir_all(&local_pull_dir);
|
||||||
|
|
||||||
// 7. R-02: VFS Copy Schutz
|
// 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 to_copy = DavPath::new("/carrier_copy.iso").unwrap();
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
|
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
|
// 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 from_regular = DavPath::new("/regular.txt").unwrap();
|
||||||
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
||||||
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
|
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);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
+966
-18
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
|
||||||
MIN_TIME_COST,
|
wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
|
use sanctum::verify::verify_container;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reject_arbitrary_sqlite_database() {
|
fn test_reject_arbitrary_sqlite_database() {
|
||||||
@@ -16,12 +17,10 @@ fn test_reject_arbitrary_sqlite_database() {
|
|||||||
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
||||||
{
|
{
|
||||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
conn.execute(
|
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", [])
|
||||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
|
.unwrap();
|
||||||
[],
|
conn.execute("INSERT INTO users (name) VALUES ('Alice')", [])
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
|
// 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 (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&db_path).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();
|
db.checkpoint().unwrap();
|
||||||
drop(db);
|
drop(db);
|
||||||
|
|
||||||
@@ -169,7 +169,8 @@ fn test_reject_out_of_bounds_kdf_params() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_reject_corrupted_slot_lengths() {
|
fn test_reject_corrupted_slot_lengths() {
|
||||||
let temp_dir = std::env::temp_dir();
|
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() {
|
if db_path.exists() {
|
||||||
let _ = std::fs::remove_file(&db_path);
|
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 (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
let db = Database::open(&db_path).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();
|
db.checkpoint().unwrap();
|
||||||
drop(db);
|
drop(db);
|
||||||
|
|
||||||
@@ -214,3 +216,782 @@ fn test_reject_corrupted_slot_lengths() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&db_path);
|
let _ = std::fs::remove_file(&db_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_slot_amplification_and_excessive_slots_dos() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||||
|
|
||||||
|
// 1. Fall: 0 Slots (leere meta-Tabelle)
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let res = db.read_slots();
|
||||||
|
assert!(res.is_err());
|
||||||
|
let err_msg = res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("leer oder beschädigt"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
|
.unwrap();
|
||||||
|
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db_reopened = Database::open(&db_path).unwrap();
|
||||||
|
let slots = db_reopened
|
||||||
|
.read_slots()
|
||||||
|
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
|
||||||
|
assert_eq!(slots.len(), 1);
|
||||||
|
assert_eq!(slots[0].slot_id, 0);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||||
|
.unwrap();
|
||||||
|
let slots = db
|
||||||
|
.read_slots()
|
||||||
|
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
|
||||||
|
assert_eq!(slots.len(), 2);
|
||||||
|
assert_eq!(slots[0].slot_id, 0);
|
||||||
|
assert_eq!(slots[1].slot_id, 1);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..3 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
rusqlite::params![
|
||||||
|
i,
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let open_res = Database::open(&db_path);
|
||||||
|
assert!(
|
||||||
|
open_res.is_err(),
|
||||||
|
"Database::open muss 3 Slots sofort abweisen"
|
||||||
|
);
|
||||||
|
let err_msg = open_res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..100 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
rusqlite::params![
|
||||||
|
i,
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let open_res = Database::open(&db_path);
|
||||||
|
assert!(
|
||||||
|
open_res.is_err(),
|
||||||
|
"100 Slots müssen sofort abgewiesen werden"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..1000 {
|
||||||
|
stmt.execute(rusqlite::params![
|
||||||
|
i,
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let open_res = Database::open(&db_path);
|
||||||
|
assert!(
|
||||||
|
open_res.is_err(),
|
||||||
|
"1000 Slots müssen sofort abgewiesen werden"
|
||||||
|
);
|
||||||
|
let err_msg = open_res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_duplicate_and_invalid_slot_ids() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||||
|
|
||||||
|
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let res = db.read_slots();
|
||||||
|
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
|
||||||
|
let err_msg = res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Doppelte Slot-ID 0"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!(
|
||||||
|
"test_invalid_slot_id_{}.sanctum",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let res = db.read_slots();
|
||||||
|
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
|
||||||
|
let err_msg = res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Ungültige Slot-ID 5"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
|
||||||
|
{
|
||||||
|
let db_path = temp_dir.join(format!(
|
||||||
|
"test_missing_slot_0_{}.sanctum",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE meta (
|
||||||
|
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||||
|
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||||
|
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||||
|
);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
b"SANCTUM\0",
|
||||||
|
&salt[..],
|
||||||
|
¶ms_json,
|
||||||
|
&wrapped_dek[..],
|
||||||
|
&nonce[..],
|
||||||
|
&tag[..]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let res = db.read_slots();
|
||||||
|
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
|
||||||
|
let err_msg = res.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
|
||||||
|
"Fehlermeldung: {err_msg}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&db_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_container_meta_authenticate_defense_in_depth() {
|
||||||
|
use sanctum::storage::{ContainerMeta, SlotMeta};
|
||||||
|
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: MIN_MEMORY_COST_KIB,
|
||||||
|
time_cost: MIN_TIME_COST,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let valid_slot_0 = SlotMeta {
|
||||||
|
slot_id: 0,
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid_slot_1 = SlotMeta {
|
||||||
|
slot_id: 1,
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A. Leere Slots -> sofort None
|
||||||
|
let meta_empty = ContainerMeta {
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
slots: vec![],
|
||||||
|
};
|
||||||
|
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
|
||||||
|
|
||||||
|
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
|
||||||
|
let meta_3_slots = ContainerMeta {
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
slots: vec![
|
||||||
|
valid_slot_0.clone(),
|
||||||
|
valid_slot_1.clone(),
|
||||||
|
valid_slot_0.clone(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
|
||||||
|
|
||||||
|
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
|
||||||
|
let meta_no_slot0 = ContainerMeta {
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
slots: vec![valid_slot_1.clone()],
|
||||||
|
};
|
||||||
|
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
|
||||||
|
|
||||||
|
// D. Ungültige Slot-ID (> 1) -> sofort None
|
||||||
|
let mut invalid_slot = valid_slot_0.clone();
|
||||||
|
invalid_slot.slot_id = 99;
|
||||||
|
let meta_invalid_id = ContainerMeta {
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
slots: vec![valid_slot_0.clone(), invalid_slot],
|
||||||
|
};
|
||||||
|
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
|
||||||
|
|
||||||
|
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
|
||||||
|
let meta_valid_1 = ContainerMeta {
|
||||||
|
version: 2,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped_dek.clone(),
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
slots: vec![valid_slot_0.clone()],
|
||||||
|
};
|
||||||
|
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use sanctum::upgrade::{
|
use sanctum::upgrade::{
|
||||||
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
run_upgrade, validate_trusted_comment_version, verify_minisign_signature,
|
||||||
|
verify_release_manifest_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
|
||||||
|
SANCTUM_RELEASE_PUBKEY_BACKUP,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -12,7 +14,11 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
|||||||
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
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]
|
#[test]
|
||||||
@@ -25,7 +31,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
|
|||||||
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
|
||||||
|
|
||||||
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
|
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"));
|
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
|
// Ein anderer, unautorisierter Public Key
|
||||||
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
|
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
|
||||||
let res = verify_minisign_signature(payload, sig_text, other_key);
|
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]
|
#[test]
|
||||||
@@ -55,10 +67,127 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let res = run_upgrade(&options);
|
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();
|
let err_msg = res.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
|
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
|
||||||
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.id, 27);
|
||||||
assert_eq!(release.tag_name, "v0.5.0");
|
assert_eq!(release.tag_name, "v0.5.0");
|
||||||
assert_eq!(release.assets.len(), 3);
|
assert_eq!(release.assets.len(), 3);
|
||||||
|
|||||||
Reference in New Issue
Block a user