48 Commits
Author SHA1 Message Date
harald 3693e03494 chore(release): bump version to 0.9.0 and update docs
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
2026-09-20 00:15:25 +02:00
harald 167e4b65fc feat(carrier): implement carrier format V2 paged manifest and secondary index 2026-09-20 00:15:11 +02:00
harald 02f731f57d chore(packages): update scoop and winget manifests for v0.8.1
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
2026-09-19 23:35:22 +02:00
harald 728ac008ae chore(release): bump version to 0.8.1 and update audit log 2026-09-19 23:30:47 +02:00
harald 8ff426d1b0 fix(storage): ST-03 — wrap recursive node deletion in atomic sqlite transaction 2026-09-19 23:23:57 +02:00
harald efddc2c477 fix(storage): ST-02 — aggregate errors in recursive node deletion without swallowing 2026-09-19 23:21:30 +02:00
harald dd6b6ff94d fix(storage): ST-01 — fail-closed node name decryption filtering out corrupted entries 2026-09-19 23:19:41 +02:00
harald c21d63ad28 fix(carrier): C-04 — document hidden vault capacity limit and add 80% mount warning 2026-09-19 23:17:04 +02:00
harald c606fa8132 fix(carrier): C-03 — decouple manifest re-encryption with dirty tracking and unmount flush 2026-09-19 23:13:34 +02:00
harald 8455dc02db fix(carrier): C-02 — rolling dual-block manifest redundancy and failover recovery 2026-09-19 11:59:51 +02:00
harald ca6d54d5ca chore(packages): update scoop and winget manifests for v0.8.0
Sanctum Release / Build & Test (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Sign & Release (push) Canceled after 0s
2026-09-19 10:17:19 +02:00
harald 84b6a39f3f chore(release): bump version to 0.8.0, add SECURITY_AUDIT.md, and update changelog 2026-09-19 10:07:42 +02:00
harald 28a4bb6b70 chore(qa): format code and configure clippy lints 2026-09-19 10:04:53 +02:00
harald 45572b7370 fix(vfs): V-04 — structured anti-leak shield rules and custom filter list 2026-09-19 09:58:52 +02:00
harald 17908a64fe fix(vfs): Z-04 — implement webdav quota report 2026-09-19 09:52:56 +02:00
harald 487f999a24 doc(security): Z-03 — document wal and shm ciphertext forensics 2026-09-19 09:49:33 +02:00
harald 5a1fd7cdd8 fix(storage): Z-02 — recover poisoned sqlite mutex across threads 2026-09-19 09:49:01 +02:00
harald 07afe34ad4 fix(mount): Z-01 — zeroize decoy password in memory 2026-09-19 09:46:45 +02:00
harald bb86447039 fix(upgrade): U-03 — configure secondary backup release public key 2026-09-19 09:45:35 +02:00
harald 8dd54494b0 fix(upgrade): U-02 — validate version in minisign trusted comment against release tag 2026-09-19 09:43:39 +02:00
harald 78a9c233f8 fix(upgrade): U-01 — enforce pre-hashed minisign verification 2026-09-19 09:41:29 +02:00
harald c7c0ed85f5 fix(sync): S-09 — pull overwrite protection and conflict safety 2026-09-19 09:40:00 +02:00
harald 92db21d47e fix(sync): S-08 — document and refine glob matching in sync engine 2026-09-19 09:33:57 +02:00
harald 15adf79f48 fix(sync): S-07 — skip invalid windows filenames with files_skipped_invalid counter 2026-09-19 09:31:25 +02:00
harald 1ba67cc9e2 fix(storage): V-09 — ensure truncate chunks after shreds data blocks before deletion 2026-09-19 09:24:30 +02:00
harald 708da24d91 fix(vfs): V-08 — disallow tab control character in validate path safety 2026-09-19 09:22:46 +02:00
harald 730393423b fix(vfs): V-07 — rfc-4918 destination overwrite semantics and directory conflict check in copy 2026-09-19 09:21:16 +02:00
harald e300a764c9 fix(vfs): V-06 — sparse writes zero-fill gaps between previous end of file and seek offset 2026-09-19 09:19:56 +02:00
harald 17ac4f9358 fix(vfs): V-05 — log flush failure as error in sanctum file drop and ensure flush on close 2026-09-19 09:17:49 +02:00
harald a2c16eda72 test(crypto): M-05 — verify schema equality between standard and hidden vault containers 2026-09-19 09:15:56 +02:00
harald e496c418c5 doc(security): M-04 — document session token threat model in userland context 2026-09-19 09:15:05 +02:00
harald 2af14eef36 fix(mount): M-03 — output webdav credentials and manual mount instructions for linux 2026-09-19 09:14:18 +02:00
harald ad9148d867 fix(build): M-02 — enable overflow checks in release profile 2026-09-19 09:12:30 +02:00
harald a502845662 fix(crypto): M-01 — increase argon2id default parameters to 256 mib and 4 iterations 2026-09-19 09:12:04 +02:00
harald 52859623c2 fix(vfs): V-03 — raii memory lock guard prevents premature memory unlock on fs clone 2026-09-19 09:10:35 +02:00
harald f9733dfc73 fix(storage): S-06 — container advisory lock prevents parallel mount and concurrent writes 2026-09-19 09:05:12 +02:00
harald 9634beb744 fix(sync): S-05 — delete and shred all chunks when truncating to 0 bytes 2026-09-19 08:59:00 +02:00
harald f557a9ddfa fix(sync): S-04 — eliminate TOCTOU by recording actual bytes written 2026-09-19 08:57:34 +02:00
harald 888c86ca5e fix(sync): S-03 — symlink detection and cycle protection 2026-09-19 08:56:43 +02:00
harald 7b0f061221 fix(sync): S-02 — platform-independent path construction avoids path traversal 2026-09-19 01:01:09 +02:00
harald bdddd812ac fix(crypto): K-03 — emergency card persistence warning and rekey command 2026-09-19 00:58:39 +02:00
harald 36a4094336 fix(crypto): K-02 — format v3 chunk replay protection with generation aad 2026-09-19 00:52:13 +02:00
harald ad531d8393 fix(crypto): K-01 — format v3 canonical metadata authentication 2026-09-19 00:26:27 +02:00
harald 151fdba09d fix(vfs): V-01 — copy dispatches to CarrierFs in carrier mode 2026-09-19 00:16:04 +02:00
harald 533764487a fix(vfs): V-02 — missing chunk within expected file size causes error instead of silent truncation 2026-09-19 00:13:43 +02:00
harald 374150e5b3 fix(sync): S-01 — excluded paths no longer deleted by --delete 2026-09-19 00:12:14 +02:00
harald fba7f305e3 release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Sign & Release (push) Canceled after 0s
- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
2026-09-18 23:40:35 +02:00
harald 1cdb30147b release: v0.7.1 — Security-Patch (R-01 bis R-06)
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
- R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code (LEGAL.md, README.md, QUICKSTART.md, main.rs, crypto/storage/recovery/verify.rs)
- R-02: Lückenloser Carrier-Schutz in SanctumFs::copy (Quelle & Ziel) und sync (Pull-Skip & Push-Schutz)
- R-03: Shared Dateinamen-Validierung (validate_node_name) in pathutil.rs, durchgesetzt in storage.rs und vfs.rs
- R-04: Fail-closed Release-Packaging & obligatorische Minisign-Signatur in CI (.gitea/workflows/release.yaml) und Scripts
- R-05: Session-Token Beseitigung im argv: In-Process Win32 WNetAddConnection2W/WNetCancelConnection2W, kein gio argv-Token, Multi-Auth HTTP Middleware (Basic Auth, X-Sanctum-Token, Path-Fallback) mit 401 WWW-Authenticate
- R-06: Sofortiges Löschen von SANCTUM_RECOVERY_KEY aus der Prozessumgebung
2026-09-18 19:49:38 +02:00
39 changed files with 11584 additions and 1346 deletions
+70 -7
View File
@@ -6,16 +6,18 @@ on:
- 'v*'
jobs:
# SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets
build:
name: Build & Release (Windows x86_64)
name: Build & Test (Windows x86_64)
runs-on: windows-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned
with:
toolchain: "1.85.0"
targets: x86_64-pc-windows-msvc
- name: Run Tests
@@ -47,18 +49,79 @@ jobs:
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
- name: Upload Build Artifacts
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: dist-artifacts
path: dist/
# SA-02, SA-03 & SA-04: Dedizierter Signatur- & Release-Job mit isolierten Secrets und Prüfsummen-Verifikation
sign-and-release:
name: Sign & Release
needs: [build]
runs-on: windows-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download Build Artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: dist-artifacts
path: dist
- name: Verify and Sign Checksums with Minisign
shell: pwsh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
run: |
# SA-02: Secret-Isolation — MINISIGN_SECRET_KEY ist ausschließlich in diesem dedizierten Signier-Job verfügbar
if (-not $env:MINISIGN_SECRET_KEY) {
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
exit 1
}
# SA-03: Pinned Download-Integrität für minisign.exe mit zwingender SHA-256 Validierung
$MinisignUrl = "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip"
$ExpectedMinisignHash = "b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031"
if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) {
Write-Host "Lade gepinnten Minisign-Release herunter: $MinisignUrl"
Invoke-WebRequest -Uri $MinisignUrl -OutFile "minisign.zip"
$ActualHash = (Get-FileHash -Path "minisign.zip" -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedMinisignHash) {
Write-Error "KRITISCHER SICHERHEITSFEHLER: Minisign SHA-256 Prüfsumme ungültig!`nErwartet: $ExpectedMinisignHash`nErhalten: $ActualHash`nAbbruch."
exit 1
}
Write-Host "Minisign SHA-256 Integrität erfolgreich verifiziert ($ActualHash)."
Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin"
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
} else {
$MinisignExe = "minisign"
}
$KeyFile = "sanctum-ci-release.key"
[System.IO.File]::WriteAllText($KeyFile, $env:MINISIGN_SECRET_KEY)
try {
& $MinisignExe -S -s $KeyFile -m "dist/SHA256SUMS.txt" -W -x "dist/SHA256SUMS.txt.minisig"
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "dist/SHA256SUMS.txt.minisig")) {
Write-Error "Minisign-Signierung fehlgeschlagen!"
exit 1
}
} finally {
Remove-Item $KeyFile -Force -ErrorAction SilentlyContinue
}
- name: Create Gitea Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: |
dist/*.zip
dist/SHA256SUMS.txt
target/release/sanctum.exe
dist/SHA256SUMS.txt.minisig
body_path: CHANGELOG.md
draft: false
prerelease: false
+196
View File
@@ -5,6 +5,202 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
## [0.9.0] - 2026-09-20
### Paged Manifest für den Hidden Vault (Aufhebung der Kapazitätsgrenze)
Dieses Release führt das **Carrier-Format V2** für den Hidden Vault ein. Es hebt die bisherige Kapazitätsgrenze von ca. 7.000 Dateien vollständig auf, führt eine skalierbare Paged-Manifest-Architektur ein und optimiert Performanz sowie Resilienz signifikant.
#### Formaterweiterung Carrier V2 (D-01 bis D-05)
- **Carrier-Format V2 mit Paged Manifest**:
- Trennung von Superblock und Inode-Tabellen: Block 0 und Block 1 enthalten nur noch den leichtgewichtigen `CarrierSuperblock` (unter Erhalt der C-02 Dual-Block Redundanz).
- Inodes werden in separaten Inode-Pages (`CarrierInodePage`, Magic `SANCTPAG`, ~1 MB Payload je Block, ca. 4.5007.000 Inodes pro Seite) aus dem normalen Blockpool verwaltet.
- Dynamische Skalierung: Die Inode-Kapazität skaliert linear mit den verfügbaren Trägerblöcken bis zu 2^32 Blöcken.
- **D-01: Paged Loading & Fail-Soft Resilienz**:
- Erkennt automatisch V1- vs. V2-Superblöcke auf Block 0 und 1.
- Resiliente Fehlerisolation: Beschädigte oder unlesbare Inode-Seiten führen nicht zum Mount-Abbruch. Fehlerhafte Seiten werden geloggt (`error!`) und übersprungen, während alle intakten Inodes gemountet werden.
- Neuer Zähler `corrupted_pages` warnt den Benutzer beim Mounten über partielle Datenbeschädigungen.
- **D-02: Sekundärindex für Kindknoten (`children_index`)**:
- In-Memory Index `children_index: HashMap<i64, Vec<i64>>` ordnet Elternknoten-IDs ihren Kindern zu.
- Beschleunigt Pfadauflösung (`resolve_path`) und Verzeichnislisting (`read_dir`) von O(N) Tabellenscans auf O(Geschwister).
- Konsistente Nachführung bei `open`, `create_dir`, `remove_file`, `remove_dir`, `rename` und `copy`.
- **D-03: Transparente V1 → V2 Migration**:
- V1-Container werden vollkommen transparent eingelesen.
- Beim ersten Speichervorgang (`save_manifest()`) wird geprüft, ob genügend freie Blöcke für die Seitenaufteilung vorhanden sind.
- Reicht der Platz nicht aus, bricht der Vorgang sauber ab, ohne den Container zu beschädigen. Reicht der Platz, wird der Container nahtlos auf Format V2 migriert.
- **D-04 & D-05: Deterministische Seitenaufteilung & Blockfreigabe**:
- Deterministische Sortierung der Inodes nach ID garantiert reproduzierbare Seitenbelegungen.
- Dynamische Blockfreigabe: Verringert sich die Inode-Anzahl (z. B. durch Löschungen), werden überzählige Seitenblöcke mittels kryptografischem Zufallsrauschen geschreddert (`shred_carrier_block`) und an den Freispeicher zurückgegeben.
- Dynamische Block-Warnung: Die statische 80%-Manifestgrenze aus C-04 wird durch eine Warnung bei absolutem Träger-Blockmangel (< 20 Blöcke oder < 5% Restkapazität) ersetzt.
## [0.8.1] - 2026-09-19
### Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
Dieses Release behebt sechs Findings aus der dritten Audit-Runde in `carrier.rs` (steganografisches Dateisystem des Hidden Vault) und `storage.rs`, schließt alle verbleibenden Punkte und erreicht 39/39 (100%) behobene Audit-Findings.
#### Steganografischer Hidden Vault & Carrier-Dateisystem (C-02, C-03, C-04)
- **C-02 (HOCH): Rollierendes Dual-Block-Manifest & Failover-Recovery**:
- Beseitigung des Single Point of Failure: Das Dateisystem-Manifest wird nun abwechselnd auf Block 0 und Block 1 geschrieben (`manifest_generation: u64`).
- Beim Mounten liest Sanctum beide Blöcke, wählt die neuere Generation und stellt den Tresor selbst bei Beschädigung (z. B. Bitrot) eines Blocks transparent wieder her.
- Vollständige Abwärtskompatibilität: Block 1 wird für bestehende Container automatisch reserviert.
- **C-03 (MITTEL): Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking**:
- Metadatenänderungen (`create_dir`, `create_file`, `remove_file`, `remove_dir`, `rename`, `copy`, `drop`) markieren das Manifest als `dirty`, anstatt jedes Mal synchron 1 MB per AES-256-GCM neu zu verschlüsseln.
- Synchrone Sicherung erfolgt gebündelt bei Datei-`flush()`, unmount/drop oder nach 50 Operationen.
- Gewährleistet massive Performancesteigerung bei Massenoperationen im Hidden Vault.
- **C-04 (NIEDRIG): Kapazitätsgrenze dokumentiert & Warnung bei Füllstand**:
- Dokumentation der Obergrenze (~7.000 Dateien/Verzeichnisse pro 1-MB-Manifest-Block) in `README.md` und `QUICKSTART.md`.
- Automatische Warnung beim Mounten und Speichern, wenn das Manifest mehr als 80% Füllstand (~838 KB) erreicht.
#### Storage-Hygiene & Fehlerbehandlung (ST-01, ST-02, ST-03)
- **ST-01 (MITTEL): Fail-Closed bei Entschlüsselungsfehlern von Knotennamen**:
- Beseitigung des Hex-Ciphertext-Leaks (`unwrap_or(enc_name)`): Bei Entschlüsselungsfehlern (z. B. Bitrot oder unlesbare Knoten) werden betroffene Einträge in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` übersprungen und geloggt (`warn!`), anstatt rohe Hex-Strings auszuliefern.
- **ST-02 (NIEDRIG): Fehleraggregation bei rekursivem Löschen**:
- `delete_node` verschluckt Fehler bei Kindknoten nicht mehr (`let _ =`).
- Fehler werden gesammelt, protokolliert (`error!`) und führen zum Abbruch mit detaillierter Zusammenfassung, wodurch verwaiste Knoten (Orphans) verhindert werden.
- **ST-03 (NIEDRIG): Atomare SQLite-Transaktion für rekursives Löschen**:
- Das gesamte rekursive Löschen eines Baumes (inklusive kryptografischem Chunk-Shredding) wird nun in einer einzigen atomaren SQLite-Transaktion ausgeführt.
- Schlägt das Löschen eines Teilbaums fehl, wird ein automatischer Rollback ausgeführt; Teilbaumlöschungen und inkonsistente Zustände sind ausgeschlossen.
## [0.8.0] - 2026-09-19
### Security Audit Remediation & Format V3 Release
Dieses Release implementiert die vollständige Behebung aller 33 identifizierten Schwachstellen und Härtungsanforderungen aus zwei umfassenden Security-Audit-Runden, rüstet das Container-Format auf V3 auf (mit kanonischer Metadaten-Authentifizierung und Generation-gebundenem Chunk-Replay-Schutz) und führt wesentliche Sicherheits- und Notfallfunktionen ein.
#### Kryptografie & Container-Format V3 (K-01, K-02, K-03, M-01, M-05)
- **K-01: Kanonische Metadaten-Authentifizierung (Format V3)**:
- Ergänzung einer HMAC-SHA-256 Metadaten-MAC (`metadata_mac`) im Header, kryptografisch abgeleitet aus dem DEK via HKDF-SHA256 (`SANCTUM_META_MAC_V3`).
- Verhindert unbemerkte Manipulation von Verzeichnisstrukturen, Knotennamen, Dateigrößen oder Dateitypen. `sanctum verify` erkennt jegliche Abweichung sofort.
- Neuer CLI-Befehl `sanctum upgrade-format --path <container>` zur unterbrechungsfreien Migration von V1/V2 auf V3.
- **K-02: Chunk-Replay- & Reordering-Schutz (Format V3)**:
- Erweiterung des AAD für AES-256-GCM von 16 Bytes auf 24 Bytes: Bindung an `node_id` (8 Bytes), `chunk_index` (8 Bytes) und monotone `generation` (8 Bytes).
- Verhindert das Wiedereinspielen älterer gültiger Ciphertexts durch Angreifer mit Container-Dateizugriff.
- **K-03: Notfallblatt-Lebenszyklus & `rekey`-Kommando**:
- `sanctum passwd` weist nun unmissverständlich darauf hin, dass das 24-Wörter Notfallblatt (BIP-39) weiterhin gültig bleibt.
- Neues CLI-Kommando `sanctum rekey`: Generiert einen frischen DEK, verschlüsselt alle Chunks um, schreddert alte Datenblöcke und gibt eine neue 24-Wort Notfallphrase aus.
- **M-01: Erhöhung der Argon2id-Standardparameter**:
- Neue Container verwenden standardmäßig `m = 256 MiB` (262.144 KiB), `t = 4` Iterationen und `p = 4` Lanes für maximalen Schutz gegen Brute-Force-Angriffe.
- **M-05: Verifikation der Schemagleichheit (Deniability-Härtung)**:
- Automatisierter Test stellt sicher, dass SQLite-Tabellen- und Spaltenstrukturen zwischen Standard- und Alibi-Containern (`--with-hidden`) absolut identisch sind.
#### Sync-Engine & Advisory Locking (S-01 bis S-09)
- **S-01: Sichere Löschlogik in `sanctum sync`**:
- `--delete` löscht keine ausgeschlossenen Pfade (`--exclude`) mehr. Für das Löschen ausgeschlossener Dateien muss explizit `--delete-excluded` übergeben werden (analog zu rsync).
- **S-02: Plattformunabhängiger Pfadzusammenbau**:
- Robuste Zerlegung und Zusammenfügung relativer Pfade verhindert Path-Traversal auf Windows- und Unix-Systemen.
- **S-03: Symlink-Erkennung & Zyklenschutz**:
- Symlinks werden beim Sync erkannt, gezählt (`files_skipped_symlinks`) und nicht rekursiv verfolgt, um Endlosschleifen zu verhindern.
- **S-04: TOCTOU-Beseitigung bei Dateigrößen**:
- Die endgültige Knotengröße wird aus den tatsächlich geschriebenen Bytes (`bytes_written`) ermittelt statt aus vorherigen Metadaten.
- **S-05: Sicheres Kürzen auf 0 Bytes**:
- Beim Kürzen von Dateien auf 0 Bytes werden ausnahmslos alle Chunks kryptografisch geschreddert und aus der Datenbank entfernt.
- **S-06: Container Advisory Lock**:
- Verhindert gleichzeitiges Mounten und kollidierende Schreibzugriffe durch `sync`. Lock wird in Metadaten persistiert; Override via `--force`.
- **S-07: Überspringen ungültiger Windows-Dateinamen**:
- Reservierte Dateinamen (z. B. `aux.txt`, `con.dat`) werden im Sync übersprungen und gezählt (`files_skipped_invalid`), ohne den Vorgang abzubrechen.
- **S-08: Präzisierung des Glob- und Pfad-Matchings**:
- Vollständig dokumentiertes und gehärtetes Glob-Matching für Ausschlussregeln.
- **S-09: Pull-Overwrite-Schutz & Konfliktsicherheit**:
- Warnung und Schutzmechanismen vor unbeabsichtigtem Überschreiben lokaler Daten bei `sync pull`.
#### VFS, Anti-Leak Shield & Carrier (V-01 bis V-09, Z-04)
- **V-01: CarrierFs-Dispatch im Carrier-Modus**:
- `SanctumFs::copy` delegiert Kopieroperationen im Alibi-/Trägermodus korrekt an das steganografische CarrierFs.
- **V-02: Fehlerbehandlung bei fehlenden Chunks**:
- Fehlt ein Datenblock innerhalb der erwarteten Dateigröße, wird ein harter E/A-Fehler (`FsError::GeneralFailure`) geworfen statt stiller Datenkürzung.
- **V-03: RAII Memory Lock Guard**:
- Eigener RAII-Guard im `Arc` stellt sicher, dass gesperrte Speicherseiten (`VirtualLock`) beim Klonen des VFS nicht vorzeitig freigegeben werden.
- **V-04: Strukturierter Anti-Leak Shield & Custom Filterliste**:
- Kategorisierte Filterung nach exakten Dateinamen, Präfixen (`~$*`, `._*`), Suffixen (`.tmp`, `.temp`, `.crdownload`, `.part`, `~*`), NTFS Alternate Data Streams (`:`) und `.trash*`.
- Neues CLI-Flag `sanctum mount --anti-leak-list <FILE>` für anwenderspezifische Filterregeln sowie Statistik über gefilterte Dateien beim Unmount.
- **V-05: Robuste Fehlerprotokollierung in `SanctumFile::drop`**:
- Flush-Fehler beim Schließen von Dateien werden als `error!` geloggt und Flush wird bei `close` erzwungen.
- **V-06: Sparse Writes**:
- Lücken zwischen dem bisherigen Dateiende und dem Schreib-Offset werden automatisch mit Null-Bytes aufgefüllt.
- **V-07: RFC-4918 konforme Overwrite-Semantik**:
- Korrekte Behandlung von Overwrite-Headern und Verzeichnis-Konfliktprüfungen in `copy`.
- **V-08: Einheitliche Pfadsicherheit**:
- Tabulator-Steuerzeichen (0x09) werden in `validate_path_safety` konsistent abgewiesen.
- **V-09: Chunk-Shredding vor Löschung**:
- `truncate_chunks_after` schreddert vor dem Löschen alle betroffenen Chunks mit Zufallsdaten.
- **Z-04: WebDAV Quota-Report**:
- `SanctumFs::get_quota` meldet korrekte Containerbelegung und verfügbaren Host-Speicher.
#### Upgrade & Signaturprüfung (U-01 bis U-03)
- **U-01: Striktes Pre-Hashing für Minisign**:
- Minisign-Verifikation akzeptiert ausschließlich pre-gehashte Signaturen (`allow_legacy = false`).
- **U-02: Versionsvalidierung im Trusted Comment**:
- Validiert, dass die im signierten `trusted comment` hinterlegte Version exakt mit dem Release-Tag übereinstimmt (Replay-Schutz).
- **U-03: Sekundärer Backup-Release-Schlüssel**:
- Hinterlegung eines sekundären Public Keys für nahtlosen Schlüsselübergang bei Key-Rollover.
#### Plattform, Speichersicherheit & Dokumentation (M-02, M-03, M-04, Z-01, Z-02, Z-03)
- **M-02: Ganzzahl-Überlaufschutz**:
- `overflow-checks = true` im Release-Profil zur Abwehr von Integer-Overflows.
- **M-03: Linux-Mount-Instruktionen**:
- Transparente Ausgabe von WebDAV-Zugangsdaten und manuelle Mount-Befehle unter Linux.
- **M-04: Bedrohungsmodell für Session-Tokens**:
- Umfassende Dokumentation des Userland-Sicherheitsmodells und Prozessgrenzen in `THREAT_MODEL.md` und `README.md`.
- **Z-01: Sicheres Decoy-Passwort-Zeroizing**:
- Schutz des temporären Alibi-Passworts im Speicher via `Zeroizing<String>`.
- **Z-02: Mutex-Poisoning-Resilienz**:
- Automatische Erholung von vergifteten Mutexes bei Thread-Panics in SQLite-Verbindungen.
- **Z-03: WAL- und SHM-Forensik-Dokumentation**:
- Dokumentation über die flüchtige Existenz verschlüsselter WAL-/SHM-Dateien und Schredder-Mechanismen.
## [0.7.2] - 2026-09-18
### Security Audit Remediation Release (Findings SA-01 to SA-07)
Dieses Release behebt alle sieben identifizierten Schwachstellen und Härtungsanforderungen (SA-01 bis SA-07) aus dem Security-Audit von v0.7.1.
#### Security & Architecture
- **SA-01: Container-DoS / KDF-Amplification Schutz**:
- Pre-KDF strukturelle Validierung in `Database::open`, `read_slots`, `ContainerMeta::authenticate` und `recovery::to_meta`.
- Striktes Limit: Maximal 2 Slots (`slots.len() <= 2`), nur Slot-IDs 0 und 1 zulässig, Erkennung von Duplikaten, Slot 0 ist Pflicht.
- Strikte Validierung aller BLOB-Längen (Salt: 16 B, Nonce: 12 B, Tag: 16 B, Wrapped DEK: 32/40 B für Slot 0, 32/64/72 B für Slot 1) und KDF-Parametergrenzen vor jeder Argon2id-Ableitung (`derive_kek`).
- **SA-02: Release-Signierung entkoppelt & CI-Secrets isoliert**:
- Trennung des CI/CD-Release-Workflows (`release.yaml`) in separate `build`- und `sign-and-release`-Jobs.
- Das Secret `MINISIGN_SECRET_KEY` ist strikt auf den nachgelagerten Signatur-Job isoliert und für Build-/Test-Runner unzugänglich.
- **SA-03: Pinned Download-Integrität für `minisign.exe`**:
- SHA-256 Integritätsprüfung des offiziellen Minisign v0.11 Downloads (`b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031`) im CI-Workflow vor dem Entpacken und Ausführen (Fail-Closed).
- **SA-04: Immutable Action-Pinning & Toolchain-Pinning**:
- Alle externen GitHub/Gitea-Actions im Release-Workflow auf unveränderliche Commit-SHAs (`@<sha>`) fixiert.
- Rust-Toolchain explizit auf Version `1.85.0` gepinnt.
- **SA-05: Session-Tokens vollständig aus URIs verbannt**:
- Eingehende WebDAV-Requests, deren URI das Session-Token in Pfad oder Query-String enthalten (`/<token>/...` oder `?token=...`), werden sofort mit `403 Forbidden` abgewiesen.
- Authentifizierung wird strikt über HTTP-Header (`Authorization: Basic ...` oder `X-Sanctum-Token: ...`) erzwungen.
- **SA-06: Constant-Time Token-Vergleich**:
- Integration von `subtle::ConstantTimeEq` für zeitinvariante Vergleiche von Basic-Auth-Credentials und Session-Tokens zum Schutz vor Timing-Seitenkanalangriffen.
- **SA-07: Dokumentations-Klarstellung: Logische vs. Physische Datenlöschung**:
- Präzisierung im `README.md` und Code-Docstrings bzgl. der Grenzen des kryptografischen Chunk-Shreddings: Auf Flash-Medien (SSDs/NVMe) können Wear-Leveling und FTL-Blöcke physisch persistieren. Empfehlung zur Nutzung von Full-Disk-Encryption (FDE).
## [0.7.1] - 2026-09-18
### Security Patch Release (Closing Gaps R-01 to R-06)
Dieses Release schließt sechs identifizierte Restlücken (R-01 bis R-06) aus dem Security-Review von v0.7.0 und härtet Authentifizierung, Prozessisolation, Carrier-Integrität und CI/CD-Sicherheit.
#### Security & Architecture
- **R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code**:
- `LEGAL.md`: Das „100% Offline“-Versprechen wurde präzisiert. Es wird wahrheitsgemäß dokumentiert, dass keine Telemetrie oder stilles Phone-Home existiert, der Befehl `sanctum upgrade` (und `--check`) jedoch bei explizitem Nutzeraufruf die Release-API des konfigurierten Gitea-Hosts anspricht.
- Entfernung aller verbliebenen Formulierungen über unnachweisbare Abstreitbarkeit ("Plausible Deniability") oder RIPA 49.
- Neutralisierung der Nutzer- und Entwicklerdokumentation bezüglich der Alibi-Trägerdatei im Decoy-Vault.
- **R-02: Lückenloser Schutz der Trägerdatei in VFS und Sync**:
- `SanctumFs::copy`: Das Duplizieren der Trägerdatei als Quelle sowie das Überschreiben als Ziel wird nun mit `FsError::Forbidden` fail-closed abgewehrt.
- `sanctum sync` (Pull): `sync_single_file_to_host` verweigert den Abzug der Trägerdatei via `assert_not_carrier`. Der rekursive Pull-Vorgang (`collect_and_pull_dir`) überspringt die Trägerdatei vollständig und spiegelt sie nicht auf den Host.
- `sanctum sync` (Push): Verhindert das Überschreiben der bestehenden Trägerdatei durch Push-Operationen.
- **R-03: Vereinheitlichte Validierung von Dateinamen (Path-Traversal & Gerätenamen)**:
- Auslagerung der kanonischen Prüffunktion `validate_node_name` in das gemeinsame Modul `src/pathutil.rs`.
- Durchgängige Durchsetzung in `storage.rs` (`create_node_in_vault`, `rename_node_in_vault`) und `vfs.rs` (`create_node`, `rename_node`).
- Strikte Zurückweisung von Pfadtrennern, Null-Bytes, Steuerzeichen, Verzeichnisreferenzen (`.`, `..`) und reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
- **R-04: Fail-Closed Release-Packaging und CI/CD-Signaturpflicht**:
- `package-release.ps1` und `package-release-linux.ps1`: Das Fehlen von `minisign.exe` oder `sanctum-release.key` bricht die Paketerstellung sofort mit `exit 1` ab (kein Warning-and-continue mehr).
- `publish-release.ps1`: Bricht die Veröffentlichung mit Fehler ab, wenn `SHA256SUMS.txt.minisig` fehlt.
- `.gitea/workflows/release.yaml`: Automatischer Minisign-Signaturschritt mittels Secret `MINISIGN_SECRET_KEY` und Pflicht-Upload von `SHA256SUMS.txt.minisig` zu jedem Gitea-Release.
- **R-05: Beseitigung von Session-Token im Argumentvektor (argv)**:
- Windows Netzlaufwerk-Mounting: Vollständige Umstellung von externem `net use` Kindprozess auf die in-process Win32-APIs `WNetAddConnection2W` und `WNetCancelConnection2W` (aus `mpr.dll`). Das Session-Token wird als Passwort übergeben; die Remote-URL `http://127.0.0.1:<port>/` enthält kein Token mehr im Prozess-Argumentvektor oder in der Prozessliste.
- Linux: Beseitigung des Tokens aus `gio mount` Argumenten.
- WebDAV HTTP-Middleware: Unterstützung von HTTP Basic Auth (`Authorization: Basic <base64>`), `X-Sanctum-Token`-Header und Pfad-Präfix-Fallback (`/<token>/`). Unauthentifizierte Anfragen werden mit standardkonformem `401 Unauthorized` und `WWW-Authenticate: Basic realm="Sanctum"` beantwortet; ungültige Host-Header mit `403 Forbidden`.
- **R-06: Speicher-Hygiene bei Notfallschlüsseln**:
- In `src/main.rs` wird die Umgebungsvariable `SANCTUM_RECOVERY_KEY` nach dem erfolgreichen Einlesen sofort mittels `std::env::remove_var` aus dem Prozessspeicher entfernt.
## [0.7.0] - 2026-09-18
### Security Hardening Release
Generated
+3 -1
View File
@@ -1457,11 +1457,12 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sanctum"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"aes-gcm",
"anyhow",
"argon2",
"base64",
"bip39",
"bytes",
"clap",
@@ -1482,6 +1483,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror",
"tokio",
+18 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.7.0"
version = "0.9.0"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -12,6 +12,7 @@ keywords = ["encryption", "container", "windows", "webdav", "security"]
categories = ["cryptography", "command-line-utilities", "filesystem"]
[dependencies]
base64 = "0.22"
clap = { version = "4.5", features = ["derive"] }
rpassword = "7.3"
argon2 = { version = "0.5", features = ["password-hash"] }
@@ -41,6 +42,7 @@ ureq = { version = "2.10", default-features = false, features = ["json", "tls"]
sha2 = "0.10"
semver = "1.0"
minisign-verify = "0.2"
subtle = "2.6"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
@@ -52,3 +54,18 @@ lto = true
codegen-units = 1
panic = "unwind"
strip = true
overflow-checks = true
[lints.clippy]
too_many_arguments = "allow"
type_complexity = "allow"
field_reassign_with_default = "allow"
upper_case_acronyms = "allow"
collapsible_if = "allow"
manual_dangling_ptr = "allow"
needless_borrow = "allow"
manual_strip = "allow"
redundant_pattern_matching = "allow"
needless_range_loop = "allow"
manual_range_contains = "allow"
+5 -4
View File
@@ -35,8 +35,8 @@ Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren
> [!WARNING]
> **Wichtiger Sicherheitshinweis & Haftungsausschluss für Anwender**:
> - **Schutzziel**: Die Dual-Vault-Funktion dient dem Schutz vor neugierigen Blicken (Schulterblick), Diebstahl oder beiläufigem Zwang im Alltag, indem ein plausibler Alibi-Tresor mit unkritischen Daten vorgezeigt werden kann.
> - **KEINE forensische "Plausible Deniability"**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält.
> - **Kein Schutz vor behördlicher Beschlagnahme**: Sanctum garantiert **keine** juristisch oder forensisch unnachweisbare Abstreitbarkeit ("Plausible Deniability") und ist **nicht** als RIPA-49-Schutzschild konzipiert.
> - **KEINE unnachweisbare Abstreitbarkeit**: Die Trägerdatei besitzt typischerweise eine hohe informationstheoretische Entropie (kryptografisches Rauschen). Forensische Analysten, IT-Sachverständige oder Ermittlungsbehörden können anhand von Entropie-, Chi-Quadrat- und Blockanalysen feststellen, dass der Träger verschlüsselte Daten oder Rauschen enthält.
> - **Kein Schutz vor behördlicher Beschlagnahme**: Sanctum garantiert **keine** juristisch oder forensisch unnachweisbare Abstreitbarkeit und ist **nicht** als Schutzschild gegen behördliche Herausgabeanordnungen konzipiert.
>
> 1. **Deutschland / Österreich / Schweiz (DACH)**:
> * **Nemo tenetur se ipsum accusare**: Niemand ist verpflichtet, sich in einem Strafverfahren selbst zu belasten (§ 136 Abs. 1 S. 2 StPO, Art. 6 EMRK). Beschuldigte haben das verfassungsrechtliche Recht zu schweigen.
@@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
* **Gültigkeit von Notfallkarten bei Passwortänderung**: Ein normales Ändern des Master-Passworts (`sanctum passwd`) ändert lediglich den KEK und belässt den Datenschlüssel (DEK) unverändert; bestehende Notfallkarten bleiben somit vollumfänglich funktionsfähig. Zur Entwertung kompromittierter Notfallkarten und vollständigen Neugenerierung des Datenschlüssels muss explizit der Befehl `sanctum rekey` ausgeführt werden.
---
@@ -64,7 +65,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
Sanctum folgt uneingeschränkt dem Prinzip **Privacy by Design and by Default** (Art. 25 DSGVO):
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie.
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie. Es gibt keine automatischen Hintergrund-Pings oder stillen Verbindungen.
* **Keine unaufgeforderten Netzwerkverbindungen**: Sanctum stellt standardmäßig keine ausgehenden Internetverbindungen her. Die einzige bewusste Ausnahme ist der Befehl `sanctum upgrade` (bzw. `sanctum upgrade --check`), welcher ausschließlich nach expliziter manueller Eingabe durch den Anwender Kontakt mit dem konfigurierten Gitea-Host aufnimmt. Jedes Update-Manifest wird dabei kryptografisch gegen den fest eingebetteten Minisign-Herstellerschlüssel verifiziert.
* **Keine Speicherung personenbezogener Daten**: Sanctum speichert lokal keinerlei Protokolle über eingegebene Passwörter, Benutzer-IDs oder Dateinamen außerhalb des verschlüsselten Containers.
* **Windows Host-Hygiene**: Durch das integrierte *Anti-Leak-Shield* und den *RAM-Paging-Schutz* (`VirtualLock`) werden Spuren im Host-System (Thumbs.db, temporäre Dateien, Auslagerungsdatei) aktiv unterbunden.
+8 -1
View File
@@ -37,6 +37,7 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
* **Kapazität & Skalierung (Format V2)**: Dank Paged-Manifest-Architektur dynamisch skalierbar (~4.5007.000 Dateien je 1-MB-Manifestseite, keine starre 7.000er-Grenze mehr). Sanctum warnt beim Mounten automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
---
@@ -73,7 +74,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode
```powershell
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
```
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort. Hinweis: Das Notfallblatt bleibt danach weiterhin gültig!).*
* **Notfallkarte verloren oder kompromittiert? Schlüssel erneuern (Rekeying)**:
```powershell
sanctum.exe rekey --path "D:\Tresor\daten.sanctum"
```
*(Generiert einen frischen Verschlüsselungsschlüssel, verschlüsselt alle Chunks um und entwertet das bisherige Notfallblatt unwiderruflich. Gibt eine neue 24-Wort Notfallkarte aus).*
---
+27 -6
View File
@@ -36,11 +36,13 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
- **Swap-Attack-Schutz**:
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
- **Dual-Vault (Multi-Slot & Carrier)**:
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristische "Plausible Deniability" gegen behördliche Beschlagnahme).
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
*Carrier-Format V2 mit Paged Manifest*: Das steganografische Dateisystem des Hidden Vaults nutzt eine skalierbare Paged-Manifest-Architektur. Blöcke 0 und 1 speichern den redundanten Superblock (C-02), während Inodes über dedizierte Inode-Pages (~1 MB Nutzdaten je Seite, ca. 4.5007.000 Inodes pro Seite) dynamisch aus dem Blockpool verwaltet werden. Die Kapazität ist nicht mehr auf 7.000 Dateien limitiert, sondern skaliert dynamisch mit den verfügbaren Trägerblöcken. Robuste Fail-Soft-Resilienz (D-01) isoliert Seitenbeschädigungen, ein In-Memory Sekundärindex (D-02) beschleunigt Pfadoperationen auf O(Geschwister), und Sanctum warnt beim Einbinden automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
- **Dateinamen-Verschlüsselung**:
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
- **Kryptografisches Chunk-Shredding**:
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene.
*Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen.
---
@@ -57,6 +59,8 @@ Der Container besteht aus exakt **einer** Host-Datei (`.sanctum`), die dynamisch
Chunks werden vor der Verschlüsselung via `lz4_flex` komprimiert. Spart die Kompression weniger als 64 Bytes (z. B. bei bereits komprimierten Bildern oder Videos), wird adaptiv die Rohform verschlüsselt.
- **Speicherplatzrückgabe**:
Durch `PRAGMA auto_vacuum = INCREMENTAL;` können freigewordene SQLite-Pages beim Aushängen oder via `sanctum compact` vollständig an das Windows-Hostdateisystem zurückgegeben werden.
- **SQLite WAL & SHM Begleitdateien (Forensik & Ciphertext-Garantie, Z-03)**:
Während des Betriebs erzeugt SQLite temporär `<container>-wal` und `<container>-shm`. Sämtliche Chunks und Inodes werden **vor** dem Schreiben im Userland via AES-256-GCM verschlüsselt, sodass Begleitdateien zu 100% ausschließlich unknackbaren Ciphertext enthalten. Beim regulären Aushängen werden alle Transaktionen via `wal_checkpoint(TRUNCATE)` in den Hauptcontainer überführt und die Begleitdateien restlos entfernt (Details siehe [THREAT_MODEL.md](THREAT_MODEL.md)).
---
@@ -107,6 +111,10 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
- **Linux & WebDAV-Integration**: Auf Linux startet Sanctum das WebDAV-VFS auf `127.0.0.1` mit lokaler Session-Token-Authentifizierung. Im Terminal werden direkt die Zugangsdaten und passende Mount-Befehle ausgegeben:
- **Benutzer**: `sanctum` | **Passwort**: Dynamisches Session-Token (Hex)
- `gio mount dav://sanctum@127.0.0.1:8443/`
- `mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/sanctum`
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
@@ -120,12 +128,21 @@ sanctum.exe unmount --drive S
---
### 4. Master-Passwort ändern
### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`)
```powershell
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
```
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen.
> [!NOTE]
> `passwd` verpackt den bestehenden Datenschlüssel (DEK) mit einem neuen KEK. Das zuvor notierte **24-Wort Notfallblatt bleibt weiterhin gültig**!
```powershell
# Master-Schlüssel erneuern & bisherige Notfallkarten entwerten (K-03):
sanctum.exe rekey --path "C:\Pfad\tresor.sanctum"
```
> [!IMPORTANT]
> `rekey` generiert einen komplett frischen DEK, verschlüsselt alle Datenblöcke um und gibt ein **neues 24-Wort Notfallblatt** aus. Alle bisherigen Notfallkarten werden dadurch **unwiderruflich ungültig**.
---
@@ -182,9 +199,12 @@ sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Down
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel):
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel, schont aber per --exclude ausgeschlossene Pfade):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
# 3b. Radikales Löschen inklusive ausgeschlossener Pfade:
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete --delete-excluded
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
@@ -290,6 +310,7 @@ Erzeugt:
## 📄 Lizenz & Changelog
- Lizenziert unter der [MIT License](LICENSE).
- Das detaillierte Bedrohungsmodell und Sicherheitsarchitektur-Dokumentation findest du in [`THREAT_MODEL.md`](THREAT_MODEL.md).
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
+132
View File
@@ -0,0 +1,132 @@
# Sanctum Security Audit & Remediation Log (v0.9.0)
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests. Abschnitt 5 dokumentiert die Formaterweiterung Carrier V2 (Paged Manifest) aus v0.9.0.
---
## Audit-Zusammenfassung
- **Zielversion:** Sanctum v0.9.0 (Basis v0.8.1 Audit-Abschluss)
- **Behobene Findings:** 39 / 39 (100%)
- **Test-Ergebnis:** 130 / 130 Tests erfolgreich (100% Pass Rate)
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
- **Container-Format:** Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
- **Hidden Vault & Storage-Resilienz:** Carrier-Format V2 (Paged Manifest), Dual-Block rollierender Superblock (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Fail-Soft Inode-Isolation (D-01), Sekundärindex (D-02), dynamische Kapazitätsskalierung und Blockwarnung, Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
---
## Übersicht aller Findings
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|---|---|---|---|---|---|
| **C-02** | HOCH | `carrier` | Single Point of Failure beseitigt: Rollierendes Dual-Block-Manifest (Block 0 & 1, `manifest_generation`) mit transparenter Failover-Wiederherstellung | `8455dc0` | ✅ Bestanden (`test_c02_dual_block_rolling_manifest_redundancy_and_recovery`) |
| **C-03** | MITTEL | `carrier`, `vfs`, `mount` | Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking; synchrone Sicherung bei File-Flush und Unmount | `c606fa8` | ✅ Bestanden (`test_c03_manifest_dirty_decoupling_and_unmount_flush`) |
| **C-04** | NIEDRIG | `carrier`, `mount`, `doc` | Dokumentation der Hidden-Vault-Kapazität (~7.000 Inodes) & automatische 80%-Füllstandswarnung beim Mounten | `c21d63a` | ✅ Bestanden (`test_c04_manifest_capacity_limit_and_warning`) |
| **ST-01** | MITTEL | `storage` | Fail-Closed bei Knotennamen-Entschlüsselung; Beseitigung von Hex-Ciphertext-Leaks bei Bitrot/Korruption | `dd6b6ff` | ✅ Bestanden (`test_st01_fail_closed_name_decryption_no_hex_leak`) |
| **ST-02** | NIEDRIG | `storage` | Fehleraggregation beim rekursiven Löschen in `delete_node` ohne stilles Verschlucken | `efddc2c` | ✅ Bestanden (`test_st02_recursive_delete_error_propagation_not_swallowed`) |
| **ST-03** | NIEDRIG | `storage` | Atomare SQLite-Transaktion (`tx.commit()` / Rollback) beim rekursiven Löschen in `delete_node` | `8ff426d` | ✅ Bestanden (`test_st03_recursive_delete_transaction_rollback`) |
| **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.
### 4. Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
- **Rollierendes Dual-Block-Manifest (C-02):**
- Das Carrier-Manifest wird abwechselnd auf Block 0 und Block 1 geschrieben, geschützt durch ein monotones Generation-Attribut (`manifest_generation: u64`).
- Beim Mounten liest Sanctum beide Blöcke. Ist ein Block korrumpiert (z. B. Bitrot oder unvollständiges Schreiben), stellt Sanctum den Hidden Vault transparent aus der intakten Kopie wieder her.
- **Entkopplung der Manifest-Neuverschlüsselung (C-03):**
- Vor v0.8.1 wurde bei jeder Metadatenänderung (Dateierstellung, Ordnererstellung, Löschen, Umbenennen, Drop) das gesamte 1-MB-Manifest synchron per AES-256-GCM neu verschlüsselt.
- v0.8.1 führt Dirty-Tracking ein: Änderungen markieren das Manifest als `dirty`. Die tatsächliche Verschlüsselung erfolgt gebündelt bei Datei-`flush()`, Unmount/Drop oder nach 50 Operationen.
- **Kapazitätsgrenze & Warnschwelle (C-04):**
- Durch den 1-MB-Block für das Manifest ergibt sich eine empfohlene Obergrenze von ~7.000 Inodes im Hidden Vault.
- Ab 80% Füllstand (~838 KB) warnt Sanctum beim Mounten und Speichern unübersehbar.
- **Fail-Closed bei Knotennamen (ST-01):**
- Bei Fehlschlagen der Entschlüsselung von Knotennamen werden keine Hex-Ciphertext-Fragmente mehr exponiert (`unwrap_or(enc_name)` eliminiert).
- Unlesbare Knoten werden mit `warn!` geloggt und in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` fail-closed übersprungen.
- **Fehleraggregation & Transaktions-Rollback bei rekursivem Löschen (ST-02 & ST-03):**
- Fehler beim Löschen von Kindknoten werden nicht mehr ignoriert (`let _ =` eliminiert), sondern aggregiert und protokolliert.
### 5. Sanctum v0.9.0 — Formaterweiterung Carrier V2 (Paged Manifest)
- **Aufhebung der C-04 Manifest-Kapazitätsgrenze:**
- In v0.8.1 war das Manifest ein einzelnes JSON-Objekt auf Block 0 und Block 1 (limitiert auf ca. 7.000 Inodes).
- Mit Format V2 wird der `CarrierSuperblock` (Block 0 & 1, dual-block rollierend C-02) von den eigentlichen Inode-Daten entkoppelt.
- Inodes werden in separaten `CarrierInodePage`-Blöcken (~1 MB Payload je Seite, ca. 4.5007.000 Inodes pro Seite) verwaltet, dynamisch allokiert aus dem Blockpool.
- **Paged Loading & Fail-Soft Resilienz (D-01):**
- Isolierte Fehlerbehandlung: Beschädigte oder unlesbare Inode-Seiten führen nicht mehr zum Gesamtabbruch des Mounts.
- Das System lädt alle lesbaren Seiten, loggt fehlerhafte Seiten als `error!` und führt einen Zähler `corrupted_pages`, der beim Einbinden gut sichtbar gemeldet wird.
- **Sekundärindex für Kindknoten (`children_index`, D-02):**
- `CarrierFsInner` pflegt einen `HashMap<i64, Vec<i64>>` In-Memory Index für Vater-Kind-Beziehungen.
- Reduziert Pfadauflösung (`resolve_path`) und Verzeichnis-Listings (`read_dir`) von $O(N)$ Tabellenscans auf $O(\text{Geschwister})$.
- **Transparente Migration & Allokations-Sicherheit (D-03, D-04, D-05):**
- V1-Container werden transparent geladen und beim ersten Schreibvorgang nach V2 migriert — inklusive Vorabprüfung, ob genügend freie Blöcke vorhanden sind.
- Freigabe überzähliger Seitenblöcke (`shred_carrier_block`) bei Inode-Löschungen.
- Ersetzung der statischen 80%-Füllstandswarnung durch eine Block-Verfügbarkeitswarnung bei `< 20` freien Blöcken oder `< 5%` Restkapazität.
- **Automatisierte Testabdeckung:** 7 neue Regressionstests in `tests/carrier_v2_paged_manifest_test.rs` decken Kapazität (>10.000 Inodes), Migration, Fail-Soft-Isolation, Sekundärindex-Konsistenz, Pfadauflösungs-Performance, Out-of-Space-Abbruch und Blockfreigabe ab (Gesamttests: 130/130 erfolgreich).
---
## Verifikationsnachweis
Alle Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt (130 / 130 Tests). Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
+128
View File
@@ -0,0 +1,128 @@
# Threat Model & Sicherheitsarchitektur von Sanctum
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.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.
---
## 6. Hidden Vault Carrier-Dateisystem & Redundanzmodell (Format V2)
### 6.1 Redundanzhierarchie: Superblock vs. Inode-Pages
Mit Sanctum v0.9.0 führt der Hidden Vault das Carrier-Format V2 (Paged Manifest) ein. Das Redundanzmodell unterscheidet bewusst zwischen Metadaten-Knotenpunkten und Inode-Seiten:
1. **Carrier-Superblock (Vollständige Redundanz auf Block 0 & Block 1)**:
- Der Superblock enthält die essenziellen Dateisystem-Parameter (`root_id`, `next_inode_id`, `free_blocks`, `page_block_indices`) sowie einen monotonen Generationszähler (`manifest_generation`) und CRC32-Prüfsummen.
- Er wird abwechselnd auf Block 0 und Block 1 geschrieben (rollierendes C-02-Schema). Ist einer der beiden Blöcke beschädigt oder unvollständig geschrieben, stellt Sanctum den Superblock transparent aus dem intakten Block wieder her.
2. **Inode-Pages (Dynamisch allokiert, nicht redundant dupliziert)**:
- Inodes werden in 1-MB-Seiten (`CarrierInodePage`, Magic `SANCTPAG`) im normalen Träger-Blockpool gespeichert.
- Zur Maximierung der nutzbaren Speicherkapazität werden Inode-Pages **nicht** blockweise gespiegelt. Stattdessen schützt Sanctum die Konsistenz über Fail-Soft-Isolation (D-01): Fällt eine einzelne Seite durch Bitrot oder Entschlüsselungsfehler aus, wird ausschließlich diese Seite übersprungen. Das restliche Dateisystem bleibt vollständig mountbar und lesbar.
- Erkannte Seitenbeschädigungen werden beim Mounten und in den Systemlogs (`corrupted_pages`) unübersehbar gemeldet.
### 6.2 Pfadauflösungs- und DoS-Schutz (`children_index`, D-02)
- Bei sehr großen Dateisystemen (>10.000 Inodes) führt eine lineare Suche über alle Inodes bei jedem Pfadsegment zu quadratischer Laufzeitkomplexität ($O(N)$ pro Segment).
- Sanctum v0.9.0 hält einen In-Memory-Sekundärindex (`children_index: HashMap<i64, Vec<i64>>`), der Pfadauflösungen auf $O(\text{Geschwister})$ reduziert. Dies verhindert CPU-Erschöpfungs-Angriffe (ReDoS/Algorithmic Complexity Attacks) beim Traversieren tiefer Verzeichnisstrukturen.
### 6.3 Speicherallokationsgrenzen & Transaktionssicherheit (D-03, D-05)
- **Vorabprüfung bei Migration (D-03)**: Bei der transparenten Konvertierung alter V1-Manifeste nach V2 prüft Sanctum vorab, ob ausreichend freie Trägerblöcke vorhanden sind. Reicht der Speicherplatz nicht aus, wird der Speichervorgang ohne Beschädigung des V1-Containers abgebrochen.
- **Kryptografische Blockfreigabe (D-05)**: Werden Inodes gelöscht und Inode-Pages überflüssig, werden die freigegebenen Trägerblöcke sofort mit CSPRNG-Rauschen überschrieben (`shred_carrier_block`), bevor sie an den Freispeicher-Pool zurückgegeben werden.
+3 -3
View File
@@ -1,12 +1,12 @@
{
"version": "0.6.0",
"version": "0.9.0",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.0/sanctum-v0.9.0-windows-x86_64.zip",
"hash": "47e26713880b812a332f7ee09737ace2ca3d7471d6375c0febe2d9b040ee8b43",
"bin": "sanctum.exe"
}
},
+4 -4
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.6.0
PackageVersion: 0.9.0
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
@@ -18,7 +18,7 @@ Tags:
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.0
Installers:
- Architecture: x64
InstallerType: zip
@@ -26,7 +26,7 @@ Installers:
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.0/sanctum-v0.9.0-windows-x86_64.zip
InstallerSha256: 47e26713880b812a332f7ee09737ace2ca3d7471d6375c0febe2d9b040ee8b43
ManifestType: singleton
ManifestVersion: 1.6.0
+10 -1
View File
@@ -115,11 +115,20 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur aktualisiert: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
} else {
Write-Error "Minisign-Signierung fehlgeschlagen!"
exit 1
}
} else {
Write-Error "minisign.exe nicht gefunden ($MinisignExe)! Release-Erstellung abgebrochen."
exit 1
}
} else {
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen."
exit 1
}
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
+5 -3
View File
@@ -110,7 +110,7 @@ $SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (Test-Path $KeyFile) {
if (Test-Path $MinisignExe) {
if (Test-Path $SigFile) { Remove-Item $SigFile -Force }
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile
& $MinisignExe -S -s $KeyFile -m $ChecksumFile -W -x $SigFile -t "version:$Version"
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
Write-Host "[OK] Minisign-Signatur erstellt: dist\SHA256SUMS.txt.minisig" -ForegroundColor Green
} else {
@@ -118,10 +118,12 @@ if (Test-Path $KeyFile) {
exit 1
}
} else {
Write-Warning "minisign.exe nicht gefunden ($MinisignExe). Signatur wurde uebersprungen."
Write-Error "minisign.exe nicht gefunden ($MinisignExe)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
exit 1
}
} else {
Write-Warning "sanctum-release.key nicht gefunden ($KeyFile). Signatur wurde uebersprungen."
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
exit 1
}
# Abschluss-Zusammenfassung
+6
View File
@@ -27,6 +27,12 @@ if (-not (Test-Path $ZipFile)) {
& (Join-Path $ScriptDir "package-release.ps1")
}
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
if (-not (Test-Path $SigFile)) {
Write-Error "Obligatorische Minisign-Signaturdatei '$SigFile' nicht gefunden! Ein Release ohne kryptografische Signatur darf nicht veroeffentlicht werden."
exit 1
}
# .env-Datei laden falls vorhanden (ist in .gitignore)
$EnvFile = Join-Path $ProjectRoot ".env"
if (Test-Path $EnvFile) {
+792 -63
View File
File diff suppressed because it is too large Load Diff
+498 -75
View File
@@ -7,20 +7,23 @@ use argon2::{Algorithm, Argon2, Params, Version};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION_V1: u32 = 1;
pub const FORMAT_VERSION_V2: u32 = 2;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2;
pub const FORMAT_VERSION_V3: u32 = 3;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V3;
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
pub const COMPRESSION_NONE: u8 = 0x00;
pub const COMPRESSION_LZ4: u8 = 0x01;
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
pub const DEFAULT_TIME_COST: u32 = 3;
pub const DEFAULT_MEMORY_COST_KIB: u32 = 256 * 1024; // 256 MB (262_144 KiB)
pub const DEFAULT_TIME_COST: u32 = 4;
pub const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -101,9 +104,18 @@ pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
/// RAII-Guard für kurzzeitige Stack-Puffer, um Lock-Leaks bei Fehlern oder Rückgabe zu verhindern (V-03).
struct ScopedMemoryLock(*const u8, usize);
impl Drop for ScopedMemoryLock {
fn drop(&mut self) {
crate::windows::unlock_memory(self.0, self.1);
}
}
pub fn derive_kek(
password: &str,
salt: &[u8],
salt: &[u8; 16],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
@@ -119,12 +131,11 @@ pub fn derive_kek(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
let _lock_guard = ScopedMemoryLock(kek.as_ptr(), 32);
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
if let Err(e) = res {
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
}
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
Ok(kek)
}
@@ -133,6 +144,7 @@ pub fn derive_kek(
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
let _lock_guard = ScopedMemoryLock(dek.as_ptr(), 32);
OsRng.fill_bytes(&mut *dek);
dek
}
@@ -145,10 +157,7 @@ pub fn generate_salt() -> [u8; 16] {
}
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
pub fn wrap_key_payload(
kek: &[u8; 32],
payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -183,17 +192,18 @@ pub fn unwrap_key_payload(
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
.map_err(|_| {
anyhow::anyhow!(
"Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"
)
})?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
@@ -206,7 +216,10 @@ pub fn unwrap_dek(
) -> Result<Zeroizing<[u8; 32]>> {
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
bail!(
"Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}",
payload.len()
);
}
let mut dek = Zeroizing::new([0u8; 32]);
@@ -241,8 +254,7 @@ pub fn wrap_slot1_payload(
}
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload) für Dummy-Slot-Längenparität im Header.
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
let mut wrapped_dek = vec![0u8; 72];
let mut nonce = [0u8; 12];
@@ -266,7 +278,8 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
aad
}
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
pub fn set_allow_legacy_names(allow: bool) {
@@ -290,7 +303,11 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
};
let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
let tag = match cipher.encrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
&aad,
&mut buffer,
) {
Ok(t) => t,
Err(_) => return String::new(),
};
@@ -304,7 +321,12 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
pub fn decrypt_node_name_ext(
dek: &[u8; 32],
parent_id: i64,
stored: &str,
allow_legacy: bool,
) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
@@ -424,7 +446,11 @@ pub fn levenshtein_distance(a: &str, b: &str) -> usize {
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
@@ -472,10 +498,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
cleaned.split_whitespace().map(|s| s.to_string()).collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
@@ -501,9 +524,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
format!(
"Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)",
idx + 1,
word,
sug
)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
format!(
"Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)",
idx + 1,
word
)
};
invalid_words.push(msg);
}
@@ -538,6 +570,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
Ok(dek)
}
/// Berechnet HMAC-SHA256 gemäß RFC 2104.
pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
let mut key_block = [0u8; 64];
if key.len() > 64 {
let mut hasher = Sha256::new();
hasher.update(key);
let hash = hasher.finalize();
key_block[..32].copy_from_slice(&hash);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut k_ipad = [0u8; 64];
let mut k_opad = [0u8; 64];
for i in 0..64 {
k_ipad[i] = key_block[i] ^ 0x36;
k_opad[i] = key_block[i] ^ 0x5c;
}
let mut inner_hasher = Sha256::new();
inner_hasher.update(k_ipad);
inner_hasher.update(data);
let inner_hash = inner_hasher.finalize();
let mut outer_hasher = Sha256::new();
outer_hasher.update(k_opad);
outer_hasher.update(inner_hash);
let out = outer_hasher.finalize();
let mut result = [0u8; 32];
result.copy_from_slice(&out);
result
}
/// Leitet einen 32-Byte Schlüssel mittels HKDF-SHA256 (RFC 5869) aus IKM und Info-String ab.
pub fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let salt = [0u8; 32];
let prk = hmac_sha256(&salt, ikm);
let mut expand_input = Vec::with_capacity(info.len() + 1);
expand_input.extend_from_slice(info);
expand_input.push(0x01);
hmac_sha256(&prk, &expand_input)
}
/// Leitet den dedizierten Metadaten-MAC-Schlüssel aus dem DEK ab (Format V3 / K-01).
pub fn derive_metadata_mac_key(dek: &[u8; 32]) -> [u8; 32] {
hkdf_sha256(dek, b"SANCTUM_META_MAC_V3")
}
/// Berechnet den Metadaten-MAC über kanonisch serialisierte Knoten und Generation (Format V3 / K-01).
pub fn compute_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
) -> [u8; 32] {
let mut data = Vec::with_capacity(24 + canonical_nodes.len());
data.extend_from_slice(b"SANCTUM_META_V3\0");
data.extend_from_slice(&metadata_gen.to_le_bytes());
data.extend_from_slice(canonical_nodes);
hmac_sha256(mac_key, &data)
}
/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC gültig ist (Format V3 / K-01).
pub fn verify_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
expected_mac: &[u8; 32],
) -> bool {
let computed = compute_metadata_mac(mac_key, metadata_gen, canonical_nodes);
computed.ct_eq(expected_mac).into()
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline]
@@ -548,9 +655,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
aad
}
/// Erzeugt die 24-Byte Associated Data (AAD) für einen Chunk in Format V3 (K-02 Chunk-Replay-Schutz):
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian) || generation (8 Bytes Little-Endian).
#[inline]
pub fn build_chunk_aad_v3(node_id: i64, chunk_index: u32, generation: u64) -> [u8; 24] {
let mut aad = [0u8; 24];
aad[..8].copy_from_slice(&node_id.to_le_bytes());
aad[8..16].copy_from_slice(&(chunk_index as u64).to_le_bytes());
aad[16..24].copy_from_slice(&generation.to_le_bytes());
aad
}
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
/// sofern dadurch eine Größenreduktion erzielt wird.
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers verwendet (K-02).
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk(
dek: &[u8; 32],
@@ -558,6 +677,7 @@ pub fn encrypt_chunk(
chunk_index: u32,
plaintext: &[u8],
format_version: u32,
generation: u64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -566,7 +686,15 @@ pub fn encrypt_chunk(
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let aad_16;
let aad_24;
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
&aad_24
} else {
aad_16 = build_chunk_aad(node_id, chunk_index);
&aad_16
};
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
if plaintext.is_empty() {
@@ -591,7 +719,7 @@ pub fn encrypt_chunk(
};
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
.encrypt_in_place_detached(nonce, aad, &mut buffer)
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
@@ -602,6 +730,7 @@ pub fn encrypt_chunk(
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers geprüft (K-02).
pub fn decrypt_chunk(
dek: &[u8; 32],
node_id: i64,
@@ -610,18 +739,32 @@ pub fn decrypt_chunk(
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
format_version: u32,
generation: u64,
) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let aad_16;
let aad_24;
let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 {
aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation);
&aad_24
} else {
aad_16 = build_chunk_aad(node_id, chunk_index);
&aad_16
};
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
.decrypt_in_place_detached(nonce, aad, &mut buffer, tag)
.map_err(|_| {
anyhow::anyhow!(
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
)
})?;
if format_version >= FORMAT_VERSION_V2 {
if buffer.is_empty() {
@@ -655,7 +798,6 @@ pub fn decrypt_chunk(
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -690,6 +832,28 @@ mod tests {
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
}
#[test]
fn test_m01_default_kdf_params_256mib_4_iterations() {
let defaults = KdfParams::default();
assert_eq!(
defaults.memory_cost,
256 * 1024,
"Default memory cost must be 256 MiB (262,144 KiB)"
);
assert_eq!(
defaults.time_cost, 4,
"Default time cost must be 4 iterations"
);
assert_eq!(
defaults.parallelism, 4,
"Default parallelism must be 4 threads"
);
assert!(
validate_kdf_params(&defaults).is_ok(),
"Default KDF parameters must pass validation"
);
}
#[test]
fn test_chunk_encryption_and_swap_protection() {
let dek = generate_dek();
@@ -698,29 +862,174 @@ mod tests {
let chunk_index = 0u32;
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap();
// Reguläre Entschlüsselung (v2)
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
0,
)
.unwrap();
assert_eq!(decrypted, plaintext);
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
let swap_node_err =
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
let swap_node_err = decrypt_chunk(
&dek,
99i64,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
0,
);
assert!(swap_node_err.is_err());
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
let swap_idx_err =
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
let swap_idx_err = decrypt_chunk(
&dek,
node_id,
1u32,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
0,
);
assert!(swap_idx_err.is_err());
// Manipulation des Ciphertexts
let mut tampered_ct = ciphertext.clone();
tampered_ct[0] ^= 0x01;
assert!(
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
assert!(decrypt_chunk(
&dek,
node_id,
chunk_index,
&tampered_ct,
&nonce,
&tag,
FORMAT_VERSION_V2,
0,
)
.is_err());
}
#[test]
fn test_k02_chunk_replay_protection_with_generation_aad() {
let dek = generate_dek();
let plaintext_v1 = b"Original Chunk Data at Generation 1";
let plaintext_v2 = b"Overwritten Chunk Data at Generation 2";
let node_id = 42i64;
let chunk_index = 0u32;
// 1. Chunk mit Generation 1 verschlüsseln
let (ct1, nonce1, tag1) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v1,
FORMAT_VERSION_V3,
1,
)
.unwrap();
// Verifiziere reguläre Entschlüsselung mit Generation 1
let dec1 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
1,
)
.unwrap();
assert_eq!(dec1, plaintext_v1);
// 2. Replay-Schutz: Entschlüsselung mit falscher Generation (z. B. 2) MUSS scheitern!
let replay_err = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
2,
);
assert!(
replay_err.is_err(),
"Ciphertext von Gen 1 darf unter Gen 2 AAD nicht entschlüsselt werden"
);
// 3. Chunk überschreiben mit Generation 2
let (ct2, nonce2, tag2) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v2,
FORMAT_VERSION_V3,
2,
)
.unwrap();
let dec2 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct2,
&nonce2,
&tag2,
FORMAT_VERSION_V3,
2,
)
.unwrap();
assert_eq!(dec2, plaintext_v2);
// 4. Replay-Angriff: Angreifer spielt ct1 (Gen 1) ein, während System Gen 2 erwartet
let attack_res = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct1,
&nonce1,
&tag1,
FORMAT_VERSION_V3,
2,
);
assert!(
attack_res.is_err(),
"Replay von altem Ciphertext muss abgewehrt werden"
);
// 5. Abwärtskompatibilität: In V2 wird generation ignoriert
let (ct_v2, nonce_v2, tag_v2) = encrypt_chunk(
&dek,
node_id,
chunk_index,
plaintext_v1,
FORMAT_VERSION_V2,
0,
)
.unwrap();
let dec_v2 = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct_v2,
&nonce_v2,
&tag_v2,
FORMAT_VERSION_V2,
999, // beliebig
)
.unwrap();
assert_eq!(dec_v2, plaintext_v1);
}
#[test]
@@ -733,7 +1042,7 @@ mod tests {
let chunk_index = 0u32;
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap();
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
assert!(
@@ -743,8 +1052,17 @@ mod tests {
plaintext.len()
);
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
0,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -756,11 +1074,11 @@ mod tests {
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2, 0).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2, 0).unwrap();
assert_eq!(decrypted, random_bytes);
}
@@ -773,11 +1091,20 @@ mod tests {
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1, 0).unwrap();
assert_eq!(ciphertext.len(), plaintext.len());
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V1,
0,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -789,10 +1116,17 @@ mod tests {
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
assert_eq!(
*dek, *recovered_dek,
"Recovered DEK must match original DEK"
);
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
let messy_phrase = format!(
" {} \n\t {} ",
words[0..12].join(" "),
words[12..24].join(" \n ")
);
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
assert_eq!(*dek, *recovered_messy);
}
@@ -816,9 +1150,16 @@ mod tests {
.collect();
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
let original_last = words[23].clone();
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
words[23] = if original_last == "abandon" {
"zoo".to_string()
} else {
"abandon".to_string()
};
let corrupted_phrase = words.join(" ");
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
assert!(
mnemonic_to_dek(&corrupted_phrase).is_err(),
"Checksum check must fail"
);
}
#[test]
@@ -861,8 +1202,14 @@ mod tests {
assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
let legacy_format = format!(
"$h${}${}${}",
&encrypted[0..24],
&encrypted[24..56],
&encrypted[56..]
);
let decrypted_legacy =
decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
@@ -870,7 +1217,11 @@ mod tests {
let mut static_buf = filename.as_bytes().to_vec();
let static_nonce = [42u8; 12];
let static_tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
.encrypt_in_place_detached(
Nonce::from_slice(&static_nonce),
b"SANCTUM_NODE_NAME",
&mut static_buf,
)
.unwrap();
let legacy_static_format = format!(
"$h${}${}${}",
@@ -883,7 +1234,8 @@ mod tests {
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
set_allow_legacy_names(true);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format)
.expect("Decrypt legacy static AAD name with flag");
assert_eq!(decrypted_static, filename);
set_allow_legacy_names(false);
@@ -905,17 +1257,33 @@ mod tests {
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
assert_eq!(
decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(),
"secrets.txt"
);
assert_eq!(
decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(),
"passwords.txt"
);
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(
decrypt_node_name(&dek, 20, &enc_folder_a).is_none(),
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
);
assert!(
decrypt_node_name(&dek, 10, &enc_folder_b).is_none(),
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
);
}
#[test]
fn test_model_a_slot_payloads() {
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
let test_kdf = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
let dek_0 = generate_dek();
@@ -923,7 +1291,8 @@ mod tests {
let carrier_node_id = 42i64;
// Slot 0 Payload: 40 Bytes
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_0.len(), 40);
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
@@ -933,7 +1302,8 @@ mod tests {
assert_eq!(recovered_cid_0, carrier_node_id);
// Slot 1 Payload: 72 Bytes
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_1.len(), 72);
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
@@ -965,15 +1335,68 @@ mod tests {
let nonce = Nonce::from_slice(&nonce_bytes);
let mut ct = malicious_plaintext.clone();
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut ct)
.unwrap();
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
let res = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct,
&nonce_bytes,
&tag_bytes,
FORMAT_VERSION_V2,
0,
);
assert!(
res.is_err(),
"Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"
);
let err_msg = res.err().unwrap().to_string();
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
assert!(
err_msg.contains("Decompression-Bomb Schutz ausgelöst"),
"Fehlermeldung erwartet: {}",
err_msg
);
}
#[test]
fn test_hmac_sha256_rfc4231_test_case_1() {
// RFC 4231 Test Case 1: Key = 20x 0x0b, Data = "Hi There"
let key = [0x0b; 20];
let data = b"Hi There";
let mac = hmac_sha256(&key, data);
let expected_hex = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
assert_eq!(hex::encode(mac), expected_hex);
}
#[test]
fn test_metadata_mac_verification() {
let dek = generate_dek();
let mac_key = derive_metadata_mac_key(&dek);
let canonical_nodes = b"node_canonical_bytes_mock_12345";
let gen = 0u64;
let mac = compute_metadata_mac(&mac_key, gen, canonical_nodes);
assert!(verify_metadata_mac(&mac_key, gen, canonical_nodes, &mac));
// Manipulierte Generation -> ungültig
assert!(!verify_metadata_mac(
&mac_key,
gen + 1,
canonical_nodes,
&mac
));
// Manipulierte Knoten-Bytes -> ungültig
assert!(!verify_metadata_mac(&mac_key, gen, b"tampered_nodes", &mac));
// Falscher Schlüssel -> ungültig
let wrong_dek = generate_dek();
let wrong_key = derive_metadata_mac_key(&wrong_dek);
assert!(!verify_metadata_mac(&wrong_key, gen, canonical_nodes, &mac));
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod carrier;
pub mod crypto;
pub mod mount;
pub mod pathutil;
pub mod platform;
pub mod recovery;
pub mod storage;
+583 -115
View File
File diff suppressed because it is too large Load Diff
+354 -119
View File
@@ -1,6 +1,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
#[cfg(unix)]
use std::process::Command;
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -32,29 +33,11 @@ pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase())
}
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
/// Trennt ein Windows-Netzlaufwerk via WNetCancelConnection2W bzw. Unix-Mountpoint via gio.
pub fn unmount_drive(drive_letter: char) -> Result<()> {
#[cfg(windows)]
{
let drive_str = format_drive(drive_letter);
let output = Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
drive_str,
stdout,
stderr
);
}
Ok(())
crate::windows::unmount_drive_wnet(drive_letter)
}
#[cfg(unix)]
{
@@ -68,74 +51,23 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
}
}
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
/// Bindet ein Windows-Netzlaufwerk via in-process WNetAddConnection2W ein (R-05, kein Token in argv)
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/`.
fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
#[cfg(windows)]
{
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
let mut output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let is_already_in_use = stderr.contains("85")
|| stderr.contains("bereits verwendet")
|| stderr.contains("already in use")
|| stdout.contains("85")
|| stdout.contains("bereits verwendet")
|| stdout.contains("already in use");
if is_already_in_use {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
let _ = Command::new("net")
.args(["use", drive_str, "/delete", "/y"])
.output();
// Zweiter Versuch nach automatischer Bereinigung
output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim erneuten Ausführen des Befehls 'net use'")?;
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
drive_str,
stdout,
stderr,
webclient_hint
);
}
Ok(())
crate::windows::mount_drive_wnet(drive_letter, port, session_token)
}
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = drive_str;
let dav_url = format!("dav://127.0.0.1:{}/", port);
let _ = Command::new("gio").args(["mount", &dav_url]).output();
let _ = (drive_letter, session_token);
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = (drive_str, port, session_token);
let _ = (drive_letter, port, session_token);
Ok(())
}
}
@@ -152,9 +84,11 @@ pub async fn mount_container(
idle_timeout: Option<u64>,
lock_on_screen_lock: bool,
anti_leak: bool,
anti_leak_list: Option<&Path>,
stealth: bool,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
let _ = &drive_str;
let _ = mount_point;
let _ = open_explorer;
let _ = enable_tray;
@@ -171,8 +105,7 @@ pub async fn mount_container(
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
@@ -181,12 +114,22 @@ pub async fn mount_container(
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
if !stealth {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
ui::step(
2,
4,
"🔑",
"Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...",
);
}
match meta.authenticate(password) {
Some(keys) => {
if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
ui::step(
3,
4,
"🔓",
"Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!",
);
}
let vault_id = keys.slot_id();
let ver = keys.version();
@@ -203,8 +146,7 @@ pub async fn mount_container(
if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
@@ -217,10 +159,13 @@ pub async fn mount_container(
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
}
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
let decoy_pass = Zeroizing::new(
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?,
);
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
})?;
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
} else {
(None, Some(carrier_node_id), 0)
@@ -229,29 +174,59 @@ pub async fn mount_container(
(None, None, 0)
};
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
(
dek,
carrier_dek,
final_carrier_node_id,
meta.version,
vault_id,
)
}
};
// S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren
let _advisory_lock = db.acquire_advisory_lock_guard(false)?;
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
let token_path_prefix = format!("/{}", session_token);
// Benutzerdefinierte Anti-Leak Filterregeln laden (V-04)
let custom_leak_rules = if let Some(path) = anti_leak_list {
match crate::vfs::load_anti_leak_list(path) {
Ok(rules) => {
if !stealth {
println!(
" • Anti-Leak Liste: {} benutzerdefinierte Regeln geladen",
rules.len()
);
}
rules
}
Err(e) => {
bail!("Fehler beim Laden der Anti-Leak-Regeln: {e}");
}
}
} else {
Vec::new()
};
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
let fs = SanctumFs::with_carrier_and_leak_rules(
db.clone(),
dek,
carrier_dek,
carrier_node_id,
version,
anti_leak,
custom_leak_rules,
vault_id,
);
let fs_leak_counter = fs.leak_counter();
let last_activity = fs.last_activity();
let fs_clone = fs.clone();
let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
@@ -302,12 +277,13 @@ pub async fn mount_container(
let server_handle = tokio::spawn(serve_webdav_loop(
listener,
dav_server,
token_path_prefix.clone(),
session_token.clone(),
shutdown_rx,
));
// Netzlaufwerk bzw. Verzeichnis einbinden
if let Err(e) = run_mount_command(&drive_str, bound_port, &session_token) {
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
let _ = fs_clone.sync_carrier_manifest();
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
return Err(e);
@@ -407,7 +383,10 @@ pub async fn mount_container(
println!();
println!(" • Container: {}", container_path.display());
#[cfg(windows)]
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
ui::cyan(&drive_str)
);
#[cfg(not(windows))]
{
if let Some(mp) = mount_point {
@@ -416,12 +395,14 @@ pub async fn mount_container(
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
println!(
" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)",
bound_port
);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
for line in format_linux_mount_instructions(bound_port, &session_token, mount_point) {
println!("{}", line);
}
}
if let Some(secs) = idle_timeout {
@@ -441,11 +422,40 @@ pub async fn mount_container(
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
if vault_id == 1 {
if let Some(corrupted) = fs_clone.carrier_corrupted_pages() {
if corrupted > 0 {
println!(
" {} Warnung (D-01): {} beschädigte Inode-Tabellenseite(n) im Hidden Vault übersprungen!",
ui::red("[!]"),
corrupted
);
}
}
if let Some((free_blocks, total_blocks)) = fs_clone.carrier_block_stats() {
if total_blocks > 0
&& (free_blocks < 20 || (free_blocks as f64 / total_blocks as f64) < 0.05)
{
println!(
" {} Warnung: Hidden Vault hat nur noch {} freie Blöcke (von {}).",
ui::yellow("[!]"),
free_blocks,
total_blocks
);
}
}
}
println!();
#[cfg(windows)]
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.",
ui::yellow("Tipp")
);
#[cfg(not(windows))]
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] zum sicheren Beenden.",
ui::yellow("Tipp")
);
println!();
}
@@ -453,11 +463,13 @@ pub async fn mount_container(
#[cfg(windows)]
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
let _console_guard =
crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Unix Signale (SIGTERM, SIGHUP)
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
@@ -554,6 +566,7 @@ pub async fn mount_container(
}
if stealth {
let _ = fs_clone.sync_carrier_manifest();
let _ = unmount_drive(drive_letter);
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
@@ -568,9 +581,14 @@ pub async fn mount_container(
#[cfg(not(windows))]
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
} else {
let _ = fs_clone.sync_carrier_manifest();
#[cfg(windows)]
{
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
print!(
" {} Trenne Windows-Netzlaufwerk {} ... ",
ui::dim("[-]"),
drive_str
);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
@@ -594,7 +612,11 @@ pub async fn mount_container(
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
print!(
" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ",
ui::dim("[-]"),
freelist
);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
@@ -612,8 +634,19 @@ pub async fn mount_container(
println!("{}", ui::green("OK"));
}
let blocked_leaks = fs_leak_counter.load(Ordering::Relaxed);
if blocked_leaks > 0 {
println!(
" • Anti-Leak Shield: {} Zugriffe auf Metadaten-/Leak-Dateien blockiert.",
ui::cyan(&blocked_leaks.to_string())
);
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!(
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
ui::green("")
);
println!();
}
@@ -675,20 +708,117 @@ pub fn is_loopback_host(host_str: &str) -> bool {
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
}
/// Erzeugt die plattformspezifischen Linux-Mount-Anweisungen und Authentifizierungsdaten (M-03).
pub fn format_linux_mount_instructions(
port: u16,
token: &str,
mount_point: Option<&std::path::Path>,
) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!(
" • WebDAV-Auth: Benutzer: sanctum | Token: {}",
token
));
lines.push(format!(
" • gio Befehl: echo {} | gio mount dav://sanctum@127.0.0.1:{}/",
token, port
));
if let Some(mp) = mount_point {
lines.push(format!(
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ {}",
port,
mp.display()
));
} else {
lines.push(format!(
" • davfs2: mount -t davfs -o username=sanctum http://127.0.0.1:{}/ /mnt/sanctum",
port
));
}
lines
}
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
let auth_str = auth_header.trim();
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
rest
} else if let Some(rest) = auth_str.strip_prefix("basic ") {
rest
} else {
return false;
};
use base64::Engine;
let decoded_bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.trim()) {
Ok(bytes) => bytes,
Err(_) => return false,
};
let decoded_str = match std::str::from_utf8(&decoded_bytes) {
Ok(s) => s,
Err(_) => return false,
};
use subtle::ConstantTimeEq;
if let Some((_user, pass)) = decoded_str.split_once(':') {
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
} else {
false
}
}
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
use subtle::ConstantTimeEq;
if let Some(val) = headers.get("X-Sanctum-Token") {
if let Ok(val_str) = val.to_str() {
return val_str
.trim()
.as_bytes()
.ct_eq(expected_token.as_bytes())
.into();
}
}
false
}
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
if session_token.is_empty() {
return false;
}
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
}
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
hyper::Response::builder()
.status(hyper::StatusCode::UNAUTHORIZED)
.header(hyper::header::WWW_AUTHENTICATE, "Basic realm=\"Sanctum\"")
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap()
}
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
token_path_prefix: String,
session_token: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
let conn_semaphore =
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
loop {
tokio::select! {
@@ -716,14 +846,14 @@ pub async fn serve_webdav_loop(
let io = TokioIo::new(stream);
let handler = dav_server.clone();
let expected_prefix = token_path_prefix.clone();
let expected_token = session_token.clone();
tokio::spawn(async move {
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
let service = service_fn(move |req| {
let h = handler.clone();
let prefix = expected_prefix.clone();
let token = expected_token.clone();
async move {
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
let host_valid = match req.headers().get(hyper::header::HOST) {
@@ -741,22 +871,46 @@ pub async fn serve_webdav_loop(
);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
let path = req.uri().path();
if !path.starts_with(&prefix) {
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
// 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598).
// Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden.
if uri_contains_token(req.uri(), &token) {
warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)");
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich
// a) HTTP Basic Auth (Authorization: Basic ...)
// b) Header X-Sanctum-Token
let mut authenticated = false;
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
if let Ok(auth_str) = auth_val.to_str() {
if check_basic_auth(auth_str, &token) {
authenticated = true;
}
}
}
if !authenticated && check_token_header(req.headers(), &token) {
authenticated = true;
}
if !authenticated {
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
return Ok::<_, Infallible>(unauthorized_response());
}
Ok::<_, Infallible>(h.handle(req).await)
}
});
@@ -767,7 +921,6 @@ pub async fn serve_webdav_loop(
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
if let Err(err) = builder.serve_connection(io, service).await {
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
@@ -806,6 +959,88 @@ mod tests {
assert_eq!(format_drive('s'), "S:");
assert_eq!(format_drive('Z'), "Z:");
}
#[test]
fn test_check_basic_auth() {
use base64::Engine;
let token = "deadbeefcafebabe0123456789abcdef";
let creds = format!("sanctum:{}", token);
let header_val = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(creds)
);
assert!(check_basic_auth(&header_val, token));
// Kleingeschriebenes basic Präfix
let lower_header = format!(
"basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token))
);
assert!(check_basic_auth(&lower_header, token));
// Falsches Token
let wrong_token_header = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken")
);
assert!(!check_basic_auth(&wrong_token_header, token));
// Ungültiges Base64 oder Format
assert!(!check_basic_auth("Basic !!!notbase64!!!", token));
assert!(!check_basic_auth("Bearer 12345", token));
assert!(!check_basic_auth("", token));
}
#[test]
fn test_check_token_header() {
let token = "deadbeefcafebabe0123456789abcdef";
let mut headers = hyper::HeaderMap::new();
assert!(!check_token_header(&headers, token));
headers.insert("X-Sanctum-Token", token.parse().unwrap());
assert!(check_token_header(&headers, token));
let mut wrong_headers = hyper::HeaderMap::new();
wrong_headers.insert("X-Sanctum-Token", "wrong".parse().unwrap());
assert!(!check_token_header(&wrong_headers, token));
}
#[test]
fn test_uri_contains_token_rejection() {
let token = "deadbeefcafebabe0123456789abcdef";
// Token im Pfad
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_path, token));
// Token im Query-String
let uri_query: hyper::Uri =
format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_query, token));
// Harmloser Pfad ohne Token
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap();
assert!(!uri_contains_token(&uri_clean, token));
// Leeres Token darf niemals matchen
assert!(!uri_contains_token(&uri_clean, ""));
}
#[test]
fn test_m03_format_linux_mount_instructions() {
let token = "fedcba9876543210fedcba9876543210";
let instructions =
format_linux_mount_instructions(8443, token, Some(std::path::Path::new("/mnt/secure")));
let all_text = instructions.join("\n");
assert!(all_text.contains("Benutzer: sanctum"));
assert!(all_text.contains(token));
assert!(all_text.contains("gio mount dav://sanctum@127.0.0.1:8443/"));
assert!(all_text
.contains("mount -t davfs -o username=sanctum http://127.0.0.1:8443/ /mnt/secure"));
}
}
+77
View File
@@ -0,0 +1,77 @@
use anyhow::{bail, Result};
/// Prüft, ob ein Name Path-Traversal-Muster oder Pfadtrennzeichen enthält (S-02).
pub fn is_path_traversal(name: &str) -> bool {
name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
}
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!(
"Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'",
name
);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_node_name() {
assert!(validate_node_name("test.txt").is_ok());
assert!(validate_node_name("").is_err());
assert!(validate_node_name(".").is_err());
assert!(validate_node_name("..").is_err());
assert!(validate_node_name("foo/bar").is_err());
assert!(validate_node_name("foo\\bar").is_err());
assert!(validate_node_name("CON").is_err());
assert!(validate_node_name("aux.txt").is_err());
assert!(validate_node_name("nul").is_err());
assert!(validate_node_name("com1.log").is_err());
assert!(validate_node_name("line\nbreak").is_err());
assert!(validate_node_name("null\0byte").is_err());
}
}
+181 -33
View File
@@ -85,11 +85,31 @@ impl HeaderBackup {
let mut slots = Vec::new();
if !self.slots.is_empty() {
if self.slots.len() > 2 {
bail!(
"Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)",
self.slots.len()
);
}
let mut seen_ids = std::collections::HashSet::new();
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if s.slot_id > 1 {
bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id);
}
if !seen_ids.insert(s.slot_id) {
bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id);
}
crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| {
anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id)
})?;
let salt_bytes =
hex::decode(&s.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
bail!(
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
salt_bytes.len()
);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
@@ -97,10 +117,24 @@ impl HeaderBackup {
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
if s.slot_id == 0 {
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len());
}
} else if s.slot_id == 1 {
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32
{
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
}
}
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
bail!(
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
nonce_bytes.len()
);
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
@@ -108,7 +142,10 @@ impl HeaderBackup {
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
bail!(
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
tag_bytes.len()
);
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
@@ -123,12 +160,19 @@ impl HeaderBackup {
header_tag,
});
}
if !seen_ids.contains(&0) {
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
let salt_bytes =
hex::decode(&self.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
bail!(
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
salt_bytes.len()
);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
@@ -139,7 +183,10 @@ impl HeaderBackup {
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
bail!(
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
nonce_bytes.len()
);
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
@@ -147,7 +194,10 @@ impl HeaderBackup {
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
bail!(
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
tag_bytes.len()
);
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
@@ -180,19 +230,28 @@ impl HeaderBackup {
/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr).
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let backup = HeaderBackup::from_meta(&meta);
let json_data = serde_json::to_string_pretty(&backup)
.context("Fehler beim Serialisieren des Header-Backups")?;
fs::write(backup_path, json_data)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
fs::write(backup_path, json_data).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht schreiben",
backup_path.display()
)
})?;
Ok(())
}
@@ -203,21 +262,25 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
let content = fs::read_to_string(backup_path)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
let content = fs::read_to_string(backup_path).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht lesen",
backup_path.display()
)
})?;
let backup: HeaderBackup = serde_json::from_str(&content)
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
let meta = backup.to_meta()?;
let db = Database::open(container_path)
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
Ok(())
}
@@ -234,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) ->
&chunk0.nonce,
&chunk0.tag,
version,
chunk0.generation,
)
.is_ok();
if is_dek0 {
@@ -256,14 +320,16 @@ pub fn restore_slot_from_recovery_key(
dek_0: Option<&[u8; 32]>,
) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
validate_password(new_password)?;
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?;
// 2. Neuen KEK mit frischem Salt ableiten
let mut salt = [0u8; 16];
@@ -273,8 +339,7 @@ pub fn restore_slot_from_recovery_key(
let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
@@ -314,7 +379,7 @@ pub fn restore_slot_from_recovery_key(
existing_slots.push(new_slot);
existing_slots.sort_by_key(|s| s.slot_id);
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Dummy-Slot-Längenparität
if !existing_slots.iter().any(|s| s.slot_id == 1) {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_slot = SlotMeta {
@@ -347,7 +412,8 @@ pub fn restore_slot_from_recovery_key(
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
Ok(())
}
@@ -361,6 +427,73 @@ pub fn restore_header_from_recovery_key(
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
}
/// Führt ein vollständiges Rekeying eines Containers durch (K-03).
/// Generiert einen neuen kryptografischen DEK, verschlüsselt alle Chunks um,
/// verpackt den neuen DEK mit dem bestehenden Passwort und gibt das neue
/// 24-Wort Notfallblatt (BIP-39 Mnemonic) zurück.
/// Das alte Notfallblatt wird dadurch unwiderruflich ungültig.
pub fn rekey_container(container_path: &Path, password: &str) -> Result<String> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let keys = meta.authenticate(password).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Master-Passwort! Authentifizierung fehlgeschlagen.")
})?;
let old_dek = keys.dek().clone();
let slot_id = keys.slot_id();
let version = keys.version();
let carrier_dek = keys.carrier_dek();
let carrier_node_id = keys.carrier_node_id();
// 1. Neuen DEK generieren
let new_dek = crate::crypto::generate_dek();
// 2. Chunks des aktiven Vaults umverschlüsseln
db.rekey_vault(slot_id, &old_dek, &new_dek, version)?;
// 3. Neuen KEK ableiten und DEK neu verpacken
let new_salt = crate::crypto::generate_salt();
let new_params = KdfParams::default();
let new_kek = derive_kek(password, &new_salt, &new_params)?;
let (new_wrapped_dek, new_nonce, new_tag) = if slot_id == 1 {
let c_dek = carrier_dek
.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &new_dek, &c_dek, c_nid)?
} else {
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot0_payload(&new_kek, &new_dek, c_nid)?
};
db.update_slot_keys(
slot_id,
&new_salt,
&new_params,
&new_wrapped_dek,
&new_nonce,
&new_tag,
)?;
// 4. Metadaten-MAC mit neuem DEK aktualisieren
db.set_active_slot_and_dek(slot_id, new_dek.clone());
db.update_metadata_mac()?;
db.checkpoint()?;
// 5. Neues BIP-39 Notfallblatt generieren
let new_phrase = crate::crypto::dek_to_mnemonic(&new_dek)?;
Ok(new_phrase)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,8 +503,10 @@ mod tests {
#[test]
fn test_header_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -392,7 +527,8 @@ mod tests {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// 1. Export
@@ -416,7 +552,12 @@ mod tests {
let restored_db = Database::open(&container_path).unwrap();
let meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
let active_dek = unwrap_dek(
&restored_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
@@ -427,7 +568,8 @@ mod tests {
#[test]
fn test_restore_from_recovery_key() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -450,7 +592,8 @@ mod tests {
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// Header zerstören
@@ -466,7 +609,12 @@ mod tests {
let rescued_db = Database::open(&container_path).unwrap();
let meta = rescued_db.read_meta().expect("Read rescued meta");
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
let unwrapped = unwrap_dek(
&new_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
+1692 -288
View File
File diff suppressed because it is too large Load Diff
+648 -123
View File
File diff suppressed because it is too large Load Diff
+32 -9
View File
@@ -131,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
pub fn print_recovery_phrase_card(phrase: &str) {
let words: Vec<&str> = phrase.split_whitespace().collect();
println!();
println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐"));
println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │"));
println!("{}", yellow("─────────────────────────────────────────────────────────────"));
println!(
"{}",
yellow("─────────────────────────────────────────────────────────────")
);
println!(
"{}",
yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")
);
println!(
"{}",
yellow("├─────────────────────────────────────────────────────────────┤")
);
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
println!(
"{}",
yellow("├─────────────────────────────────────────────────────────────┤")
);
for row in 0..8 {
let w1 = if row < words.len() {
@@ -158,7 +170,10 @@ pub fn print_recovery_phrase_card(phrase: &str) {
println!("{:<18} {:<18} {:<18}", cyan(&w1), cyan(&w2), cyan(&w3));
}
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
println!(
"{}",
yellow("└─────────────────────────────────────────────────────────────┘")
);
println!();
}
@@ -204,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
println!(" - Daten-Chunks: {}", report.total_chunks);
if report.total_bytes_decrypted > 0 {
let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0);
println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb);
println!(
" - Verifiziert: {:.2} MB (vollständig entschlüsselt)",
mb
);
}
if !report.errors.is_empty() {
@@ -217,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
println!();
if report.is_healthy() {
println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer."));
println!(
" {}",
green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.")
);
} else {
println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen."));
println!(
" {}",
red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")
);
}
println!();
}
+137 -24
View File
@@ -20,6 +20,8 @@ pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
pub const SANCTUM_RELEASE_PUBKEY_BACKUP: &str =
"RWQEeUSLlX2p4EPUPN1GjeUv34qA0p2B6wJYzcou5oOwAkkw0f+r9FmZ";
/// Repräsentiert ein Asset in einem Gitea-Release
#[derive(Debug, Deserialize, Clone)]
@@ -64,22 +66,84 @@ impl Default for UpgradeOptions {
}
}
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
pub fn verify_minisign_signature(
data: &[u8],
sig_str: &str,
pubkey_str: &str,
) -> Result<()> {
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (U-01).
///
/// Erzwingt strikt Pre-Hashing (`allow_legacy = false`), um Malleability- und Längenangriffe auszuschließen.
/// Gibt bei erfolgreicher Verifikation den kryptografisch authentifizierten `trusted comment` zurück.
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<String> {
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
let signature = minisign_verify::Signature::decode(sig_str)
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
pubkey
.verify(data, &signature, true)
.verify(data, &signature, false)
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
Ok(signature.trusted_comment().to_string())
}
/// Extrahiert die Versionsnummer aus dem Trusted Comment einer Minisign-Signatur und
/// validiert sie gegen das erwartete Release-Tag (U-02).
pub fn validate_trusted_comment_version(
trusted_comment: &str,
expected_release: &str,
) -> Result<()> {
let clean_expected = expected_release.trim().trim_start_matches(['v', 'V']);
if clean_expected.is_empty() {
bail!("Erwartetes Release-Tag darf nicht leer sein.");
}
// Durchsuche die Tokens des Trusted Comments (getrennt durch Tabs oder Leerzeichen)
let found = trusted_comment.split_whitespace().any(|token| {
let val = if let Some(stripped) = token.strip_prefix("version:") {
stripped
} else if let Some(stripped) = token.strip_prefix("release:") {
stripped
} else if let Some(stripped) = token.strip_prefix("tag:") {
stripped
} else {
token
};
let clean_val = val.trim_start_matches(['v', 'V']);
clean_val == clean_expected
});
if !found {
bail!(
"Signatur-Validierungsfehler (U-02): Der kryptografisch gesicherte Trusted Comment \
'{}' stimmt nicht mit der erwarteten Release-Version '{}' überein! \
Möglicher Replay-Angriff mit einer älteren signierten Prüfsummendatei.",
trusted_comment.trim(),
expected_release
);
}
Ok(())
}
/// Überprüft eine Minisign-Signatur für ein Release-Manifest.
///
/// Prüft primär gegen `SANCTUM_RELEASE_PUBKEY`. Falls diese Verifikation fehlschlägt,
/// wird transparent gegen den sekundären Backup-Schlüssel `SANCTUM_RELEASE_PUBKEY_BACKUP`
/// geprüft (U-03), um unterbrechungsfreie Schlüsselrotationen zu ermöglichen.
pub fn verify_release_manifest_signature(data: &[u8], sig_str: &str) -> Result<String> {
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY) {
Ok(comment) => Ok(comment),
Err(primary_err) => {
match verify_minisign_signature(data, sig_str, SANCTUM_RELEASE_PUBKEY_BACKUP) {
Ok(comment) => {
tracing::info!(
"Minisign-Signatur erfolgreich mit sekundärem Backup-Schlüssel verifiziert."
);
Ok(comment)
}
Err(_) => Err(primary_err),
}
}
}
}
/// Erkannter Paketmanager
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
@@ -138,7 +202,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
let resp = ureq::get(&api_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.set("Accept", "application/json")
.timeout(Duration::from_secs(15))
.call()
@@ -154,7 +221,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
/// Lädt eine Datei von einer URL als Text herunter.
pub fn fetch_text(url: &str) -> Result<String> {
let resp = ureq::get(url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(15))
.call()
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
@@ -187,7 +257,7 @@ pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) ->
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
let clean = ver_str.trim().trim_start_matches(['v', 'V']);
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
}
@@ -212,7 +282,10 @@ pub fn download_and_verify_binary(
expected_size: u64,
) -> Result<PathBuf> {
let resp = ureq::get(download_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(120))
.call()
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
@@ -285,7 +358,10 @@ pub fn download_and_verify_binary(
);
}
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
println!(
" • SHA-256: {} (verifiziert)",
ui::green(&calculated_hash[..16])
);
let (_, path) = temp_file
.keep()
@@ -324,12 +400,16 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version)));
println!(
" • Aktuelle Version: {}",
ui::cyan(&format!("v{}", current_version))
);
println!(" • Server: {}", options.base_url);
println!();
// S-02: Prüfung auf abweichende Server-URL
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
let is_official_server =
options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
if !is_official_server {
if !options.insecure_url {
bail!(
@@ -389,12 +469,22 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
}
// 2. Neuestes Release abfragen
println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]"));
println!(
" {} Suche nach neuesten Releases auf Gitea ...",
ui::dim("[-]")
);
let release = fetch_latest_release(&options.base_url)?;
let remote_tag = &release.tag_name;
let is_newer = is_newer_version(current_version, remote_tag);
println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) });
println!(
" • Neueste Version: {}",
if is_newer {
ui::green(remote_tag)
} else {
ui::cyan(remote_tag)
}
);
println!(" • Release-Name: {}", release.name);
if let Some(ref pub_at) = release.published_at {
println!(" • Veröffentlicht: {}", pub_at);
@@ -459,18 +549,34 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
})?;
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
println!(
" {} Lade Prüfsummen ({}) herunter ...",
ui::dim("[-]"),
CHECKSUM_ASSET_NAME
);
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
println!(
" {} Lade Minisign-Signatur ({}) herunter ...",
ui::dim("[-]"),
CHECKSUM_SIG_ASSET_NAME
);
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
println!(
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
ui::dim("[-]")
);
let trusted_comment = verify_release_manifest_signature(sums_content.as_bytes(), &sig_content)
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
validate_trusted_comment_version(&trusted_comment, remote_tag)?;
println!(
" • Signatur: {} (Minisign Ed25519, Version validiert)",
ui::green("Gültig")
);
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
let expected_hash =
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
anyhow::anyhow!(
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
TARGET_ASSET_NAME
@@ -479,7 +585,11 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
if !options.yes {
println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size));
println!(
" • Ziel-Binary: {} ({})",
target_asset.name,
ui::format_bytes(target_asset.size)
);
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
println!();
print!(
@@ -513,7 +623,10 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
println!(
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
remote_tag
);
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Pfad: {}", updated_exe.display());
println!(" • Neue Version: {}", ui::green(remote_tag));
+100 -30
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3};
use crate::storage::Database;
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
@@ -43,11 +43,13 @@ pub fn verify_container(
full_chunks: bool,
) -> Result<VerificationReport> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let mut report = VerificationReport {
container_path: container_path.display().to_string(),
@@ -67,7 +69,8 @@ pub fn verify_container(
};
// 1. SQLite B-Tree & Foreign Key Prüfung
let sqlite_issues = db.run_sqlite_integrity_check()
let sqlite_issues = db
.run_sqlite_integrity_check()
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
if !sqlite_issues.is_empty() {
report.sqlite_ok = false;
@@ -78,7 +81,10 @@ pub fn verify_container(
let meta = match db.read_meta() {
Ok(m) => {
report.format_version = m.version;
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
if m.version != FORMAT_VERSION_V1
&& m.version != FORMAT_VERSION_V2
&& m.version != FORMAT_VERSION_V3
{
report.header_ok = false;
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
}
@@ -96,13 +102,16 @@ pub fn verify_container(
};
// 3. Node-Hierarchie & Strukturprüfung
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
let (dirs, files, chunks_count) = db
.count_nodes_and_chunks()
.context("Fehler beim Zählen der Knoten und Chunks")?;
report.total_dirs = dirs;
report.total_files = files;
report.total_chunks = chunks_count;
let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?;
let all_nodes = db
.list_all_nodes()
.context("Fehler beim Laden der Knotenliste")?;
report.total_nodes = all_nodes.len();
let mut node_map = HashMap::new();
@@ -114,23 +123,33 @@ pub fn verify_container(
match node_map.get(&1) {
Some(root) => {
if !root.is_dir {
report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
report
.errors
.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
}
if root.parent_id.is_some() {
report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
report
.errors
.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
}
}
None => {
report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
report
.errors
.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
}
}
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
report
.errors
.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
report
.errors
.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
@@ -231,11 +250,36 @@ pub fn verify_container(
None
};
// 3b. Metadaten-Authentifizierung (HMAC-SHA256) für Format V3 (K-01)
if let Some(active_dek) = dek {
if let Some(ref m) = meta {
if m.version >= FORMAT_VERSION_V3 {
match db.verify_metadata_mac(active_dek) {
Ok(true) => {}
Ok(false) => {
report.errors.push(
"Kritischer Metadaten-Authentifizierungsfehler (K-01): Metadaten-MAC ungültig. Manipulationsversuch an Dateinamen, Größen oder Verzeichnisstruktur in der SQLite-Datenbank erkannt.".to_string(),
);
}
Err(e) => {
report
.errors
.push(format!("Fehler bei der Metadaten-MAC-Prüfung: {e}"));
}
}
}
}
}
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers()
let chunk_headers = db
.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?;
let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2);
let format_version = meta
.as_ref()
.map(|m| m.version)
.unwrap_or(FORMAT_VERSION_V2);
for (node_id, chunk_index) in chunk_headers {
if !node_map.contains_key(&node_id) {
@@ -257,6 +301,7 @@ pub fn verify_container(
&record.nonce,
&record.tag,
format_version,
record.generation,
) {
Ok(plaintext) => {
report.total_bytes_decrypted += plaintext.len() as u64;
@@ -319,7 +364,9 @@ mod tests {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Verzeichnis & Datei anlegen
let folder = db.create_node(1, "photos", true).unwrap();
@@ -327,21 +374,32 @@ mod tests {
// 2 Chunks schreiben
let chunk0_data = b"Sample JPEG data header and pixels";
let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
let (ct0, n0, t0) =
encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, 0, &n0, &t0, &ct0).unwrap();
let chunk1_data = b"Additional payload data bytes";
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
let (ct1, n1, t1) =
encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 1, 0, &n1, &t1, &ct1).unwrap();
db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap();
db.update_node_size_and_time(
file.id,
(chunk0_data.len() + chunk1_data.len()) as u64,
1000,
)
.unwrap();
db.checkpoint().unwrap();
// Verifizieren
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert!(
report.is_healthy(),
"Container must be healthy, report: {:?}",
report
);
assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0);
@@ -371,12 +429,14 @@ mod tests {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let file = db.create_node(1, "document.pdf", false).unwrap();
let chunk_data = b"Vital documents that must not be corrupted";
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -387,14 +447,24 @@ mod tests {
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
rusqlite::params![corrupted_ct, file.id],
).unwrap();
)
.unwrap();
drop(conn);
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
assert!(
!report.is_healthy(),
"Container must report unhealthy due to bitrot"
);
assert_eq!(
report.corrupted_chunks, 1,
"Must detect exactly 1 corrupted chunk"
);
assert!(report
.errors
.iter()
.any(|e| e.contains("AEAD/Integritätsfehler")));
let _ = fs::remove_file(&container_path);
}
+744 -114
View File
File diff suppressed because it is too large Load Diff
+245 -30
View File
@@ -2,6 +2,8 @@ use std::process::Command;
#[allow(unused_imports)]
use anyhow::{bail, Context, Result};
#[cfg(windows)]
use tracing::debug;
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
pub fn find_next_available_drive() -> Result<char> {
@@ -42,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
Command::new("explorer.exe")
.arg(&drive_path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
.with_context(|| {
format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path)
})?;
Ok(())
}
#[cfg(target_os = "macos")]
@@ -71,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
Command::new("explorer.exe")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
.with_context(|| {
format!(
"Konnte Windows Explorer für '{}' nicht öffnen",
path.display()
)
})?;
Ok(())
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
Command::new("open").arg(path).spawn().with_context(|| {
format!("Konnte macOS Finder für '{}' nicht öffnen", path.display())
})?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
@@ -87,7 +95,12 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
Command::new("xdg-open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
.with_context(|| {
format!(
"Konnte Dateimanager via xdg-open für '{}' nicht öffnen",
path.display()
)
})?;
Ok(())
}
#[cfg(not(any(windows, unix)))]
@@ -127,8 +140,7 @@ pub fn notify_shell_associations_changed() {
let _ = Command::new("update-desktop-database").spawn();
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
}
{}
}
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
@@ -141,11 +153,7 @@ pub fn register_explorer_integration() -> Result<()> {
let reg_commands = [
// 1. .sanctum Erweiterung mit ProgID verknüpfen
(
r"HKCU\Software\Classes\.sanctum",
"",
"Sanctum.Container",
),
(r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"),
// 2. ProgID Metadaten & Beschreibung
(
r"HKCU\Software\Classes\Sanctum.Container",
@@ -203,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> {
}
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
let output = cmd
.output()
.with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
@@ -244,9 +254,7 @@ pub fn unregister_explorer_integration() -> Result<()> {
];
for key in keys_to_delete {
let _ = Command::new("reg")
.args(["delete", key, "/f"])
.output();
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
}
notify_shell_associations_changed();
@@ -256,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> {
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
let _ =
std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(
home_path.join(".local/share/mime/packages/application-x-sanctum.xml"),
);
notify_shell_associations_changed();
}
Ok(())
@@ -494,9 +505,9 @@ pub fn start_session_lock_monitor(
})
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
let hwnd = hwnd_rx
.recv()
.map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?;
let hwnd = hwnd_rx.recv().map_err(|e| {
anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}")
})?;
if hwnd == 0 {
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
@@ -520,10 +531,38 @@ pub fn start_session_lock_monitor(
// ─────────────────────────────────────────────────────────────
#[cfg(windows)]
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
std::sync::Mutex::new(None);
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
#[cfg(windows)]
#[repr(C)]
#[allow(non_snake_case)]
struct NETRESOURCEW {
dwScope: u32,
dwType: u32,
dwDisplayType: u32,
dwUsage: u32,
lpLocalName: *mut u16,
lpRemoteName: *mut u16,
lpComment: *mut u16,
lpProvider: *mut u16,
}
#[cfg(windows)]
#[link(name = "mpr")]
extern "system" {
fn WNetAddConnection2W(
lpNetResource: *const NETRESOURCEW,
lpPassword: *const u16,
lpUserName: *const u16,
dwFlags: u32,
) -> u32;
fn WNetCancelConnection2W(lpName: *const u16, dwFlags: u32, fForce: i32) -> u32;
}
#[cfg(windows)]
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
const CTRL_C_EVENT: u32 = 0;
@@ -534,13 +573,15 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
match ctrl_type {
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen (in-process)
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
if let Some(dl) = *guard {
let drive_str = format!("{}:", dl.to_ascii_uppercase());
let _ = std::process::Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output();
let wide_drive: Vec<u16> =
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
}
}
}
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
@@ -681,6 +722,181 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
}
}
/// Bindet ein Windows-Netzlaufwerk über WNetAddConnection2W ein (in-process, ohne argv-Token-Leak).
#[cfg(windows)]
pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let remote_url = format!("http://127.0.0.1:{}/", port);
let mut remote_name: Vec<u16> = remote_url
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = session_token
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let nr = NETRESOURCEW {
dwScope: 0,
dwType: 1, // RESOURCETYPE_DISK
dwDisplayType: 0,
dwUsage: 0,
lpLocalName: local_name.as_mut_ptr(),
lpRemoteName: remote_name.as_mut_ptr(),
lpComment: std::ptr::null_mut(),
lpProvider: std::ptr::null_mut(),
};
let mut res = unsafe {
WNetAddConnection2W(
&nr,
password.as_ptr(),
username.as_ptr(),
0, // Nicht persistent
)
};
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
if res == 85 || res == 1202 {
debug!(
"Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...",
drive_str
);
unsafe {
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
}
res = unsafe { WNetAddConnection2W(&nr, password.as_ptr(), username.as_ptr(), 0) };
}
if res != 0 {
let webclient_hint = if res == 67 {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!(
"Laufwerk {} konnte nicht eingebunden werden (Win32 Fehlercode {}){}",
drive_str,
res,
webclient_hint
);
}
Ok(())
}
/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W (in-process).
#[cfg(windows)]
pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> {
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
let local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let res = unsafe {
WNetCancelConnection2W(local_name.as_ptr(), 0, 1 /* fForce = TRUE */)
};
// 0 = NO_ERROR, 2250 = ERROR_NOT_CONNECTED (bereits getrennt)
if res != 0 && res != 2250 {
bail!(
"Netzlaufwerk {} konnte nicht getrennt werden (Win32 Fehlercode {})",
drive_str,
res
);
}
Ok(())
}
#[cfg(not(windows))]
pub fn mount_drive_wnet(_drive_letter: char, _port: u16, _session_token: &str) -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
Ok(())
}
/// Prüft, ob ein Prozess mit der angegebenen PID auf dem lokalen System aktiv ist (S-06).
pub fn is_process_alive(pid: u32) -> bool {
#[cfg(windows)]
{
extern "system" {
fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> isize;
fn CloseHandle(handle: isize) -> i32;
fn GetExitCodeProcess(handle: isize, exit_code: *mut u32) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const STILL_ACTIVE: u32 = 259;
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return false;
}
let mut exit_code: u32 = 0;
let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
unsafe { CloseHandle(handle) };
success != 0 && exit_code == STILL_ACTIVE
}
#[cfg(not(windows))]
{
std::path::Path::new(&format!("/proc/{}", pid)).exists()
}
}
/// Ermittelt den verfügbaren freien Speicherplatz auf dem Dateisystem des angegebenen Pfads (Z-04).
pub fn get_available_disk_space<P: AsRef<std::path::Path>>(path: P) -> Option<u64> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
let p = path.as_ref();
let dir = if p.is_dir() {
p.to_path_buf()
} else {
p.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_else(|| p.to_path_buf())
};
let mut wide: Vec<u16> = dir.as_os_str().encode_wide().collect();
wide.push(0);
let mut free_bytes_available = 0u64;
let mut total_number_of_bytes = 0u64;
let mut total_number_of_free_bytes = 0u64;
extern "system" {
fn GetDiskFreeSpaceExW(
lpDirectoryName: *const u16,
lpFreeBytesAvailableToCaller: *mut u64,
lpTotalNumberOfBytes: *mut u64,
lpTotalNumberOfFreeBytes: *mut u64,
) -> i32;
}
let success = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free_bytes_available,
&mut total_number_of_bytes,
&mut total_number_of_free_bytes,
)
};
if success != 0 {
Some(free_bytes_available)
} else {
None
}
}
#[cfg(not(windows))]
{
let _ = path;
Some(1024 * 1024 * 1024 * 1024)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -697,7 +913,7 @@ mod tests {
fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive");
assert!(drive.is_ascii_alphabetic());
assert!(drive >= 'D' && drive <= 'Z');
assert!(('D'..='Z').contains(&drive));
}
#[test]
@@ -717,4 +933,3 @@ mod tests {
drop(monitor);
}
}
File diff suppressed because it is too large Load Diff
+686
View File
@@ -0,0 +1,686 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use bytes::Bytes;
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, OpenOptions};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::carrier::{
write_carrier_block, CarrierFs, CarrierFsInner, CarrierInode, CarrierManifest, CARRIER_MAGIC,
CARRIER_VERSION, CARRIER_VERSION_V2,
};
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::{Database, UnlockedKeys};
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_v2_{}_{}.sanctum", prefix, id));
path
}
struct TestEnv {
pub path: PathBuf,
pub db: Database,
pub carrier_node_id: i64,
pub auth_hidden: UnlockedKeys,
}
impl Drop for TestEnv {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn setup_carrier_env(prefix: &str, size_mb: usize) -> TestEnv {
let path = temp_db_path(prefix);
let carrier_size_bytes = (size_mb * 1024 * 1024) as u64;
let carrier_name = "carrier_v2.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
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");
TestEnv {
path,
db,
carrier_node_id,
auth_hidden,
}
}
fn load_carrier_fs(env: &TestEnv) -> CarrierFs {
CarrierFs::load(
env.db.clone(),
env.carrier_node_id,
std::sync::Arc::new(env.auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(env.auth_hidden.dek().clone()),
env.auth_hidden.version(),
true,
)
.expect("Load CarrierFs")
}
/// 1. Kapazitätstest: >10.000 Inodes künstlich erzeugen, über mehrere Seiten speichern und neu laden
#[tokio::test]
async fn test_capacity_large_inode_table() {
let env = setup_carrier_env("capacity_large", 25);
let cfs = load_carrier_fs(&env);
// Initial hat ein frischer Container 1 Seite (Block 2)
assert_eq!(cfs.page_count(), 1);
// 10.000 Inodes direkt in den Speicher einfügen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..10_000 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let inode = CarrierInode {
id,
parent_id: Some(1),
name: format!("file_{:05}.txt", i),
is_dir: false,
size: i % 500,
created_at: 1_700_000_000 + i,
modified_at: 1_700_000_000 + i,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
// Speichern erzwingen
cfs.sync_manifest().expect("Sync 10.000 inodes");
// In V2 müssen die 10.000 Inodes auf mindestens 2 Seiten aufgeteilt worden sein
let pages_after_save = cfs.page_count();
assert!(
pages_after_save >= 2,
"10.000 Inodes müssen auf mindestens 2 Seiten aufgeteilt werden, tatsächlich: {}",
pages_after_save
);
drop(cfs);
// Neu aus Trägerdatei laden
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(cfs_reloaded.page_count(), pages_after_save);
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
// Prüfe, dass beliebige Dateien korrekt existieren und Pfade auflösen
let test_file_1 = cfs_reloaded.resolve_path("/file_00000.txt");
assert!(test_file_1.is_some());
assert_eq!(test_file_1.unwrap().name, "file_00000.txt");
let test_file_mid = cfs_reloaded.resolve_path("/file_04999.txt");
assert!(test_file_mid.is_some());
assert_eq!(test_file_mid.unwrap().name, "file_04999.txt");
let test_file_end = cfs_reloaded.resolve_path("/file_09999.txt");
assert!(test_file_end.is_some());
assert_eq!(test_file_end.unwrap().name, "file_09999.txt");
let nonexistent = cfs_reloaded.resolve_path("/file_10000.txt");
assert!(nonexistent.is_none());
}
/// 2. Migrationstest: Einen V1-Container laden, modifizieren, als V2 speichern und verifizieren
#[tokio::test]
async fn test_v1_to_v2_migration() {
let env = setup_carrier_env("migration_v1_v2", 10);
// Einen V1-Container konstruieren: Block 0 und Block 1 halten CarrierManifest mit version = 1
let mut v1_manifest = CarrierManifest {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION, // 1
manifest_generation: 1,
total_blocks: 10,
free_blocks: (2..10).collect(),
next_inode_id: 3,
page_block_indices: Vec::new(),
inodes: HashMap::new(),
};
v1_manifest.inodes.insert(
1,
CarrierInode {
id: 1,
parent_id: None,
name: String::new(),
is_dir: true,
size: 0,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
v1_manifest.inodes.insert(
2,
CarrierInode {
id: 2,
parent_id: Some(1),
name: "original_v1.txt".to_string(),
is_dir: false,
size: 128,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
let dek_inner = env.auth_hidden.dek();
write_carrier_block(
&env.db,
env.carrier_node_id,
0,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
write_carrier_block(
&env.db,
env.carrier_node_id,
1,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
// 1. Unter v0.9.0 laden: V1-Format wird transparent erkannt
let cfs = load_carrier_fs(&env);
{
let inner = cfs.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION);
}
let orig = cfs.resolve_path("/original_v1.txt");
assert!(orig.is_some());
assert_eq!(orig.unwrap().id, 2);
// 2. Änderung vornehmen: Datei hinzufügen (löst Dirty-Tracking aus)
let new_file_path = DavPath::new("/new_v2.txt").unwrap();
let mut file = cfs
.open(
&new_file_path,
OpenOptions {
create_new: true,
write: true,
..Default::default()
},
)
.await
.expect("Create new file in migrated container");
file.write_bytes(Bytes::from_static(b"migrated to v2"))
.await
.unwrap();
file.flush().await.unwrap();
drop(file);
// Explizit synchronisieren
cfs.sync_manifest().expect("Sync manifest during migration");
drop(cfs);
// 3. Neu laden: Jetzt muss das Dateisystem im Format V2 vorliegen
let cfs_reloaded = load_carrier_fs(&env);
{
let inner = cfs_reloaded.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION_V2);
assert!(!inner.manifest.page_block_indices.is_empty());
}
// Beide Dateien müssen vorhanden sein
assert!(cfs_reloaded.resolve_path("/original_v1.txt").is_some());
assert!(cfs_reloaded.resolve_path("/new_v2.txt").is_some());
}
/// 3. Teilausfalltest (D-01 Fail-Soft): Beschädigung einer einzelnen Inode-Seite führt nicht zum Totalverlust
#[tokio::test]
async fn test_partial_page_corruption_resilience() {
let env = setup_carrier_env("partial_corruption", 20);
let cfs = load_carrier_fs(&env);
// Genügend Inodes erzeugen, um 2 Seiten zu belegen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..1500 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let name = if i < 750 {
format!("alpha_{:04}_{}", i, "a".repeat(800))
} else {
format!("omega_{:04}_{}", i, "z".repeat(800))
};
let inode = CarrierInode {
id,
parent_id: Some(1),
name,
is_dir: false,
size: 10,
created_at: 2000,
modified_at: 2000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync 2 pages");
let page_blocks = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.page_block_indices.clone()
};
assert!(
page_blocks.len() >= 2,
"Benötigt mindestens 2 Seiten, hat: {}",
page_blocks.len()
);
let second_page_block = page_blocks[1];
drop(cfs);
// 2. Gezielte Beschädigung: Zweite Seite in SQLite mit Rauschen überschreiben
{
let conn = rusqlite::Connection::open(&env.path).unwrap();
let mut noise = vec![0u8; 500];
OsRng.fill_bytes(&mut noise);
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = ?3",
rusqlite::params![noise, env.carrier_node_id, second_page_block],
)
.unwrap();
}
// 3. Neu laden: Darf NICHT abbrechen (Fail-Soft), sondern überspringt die defekte Seite
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(
cfs_reloaded.corrupted_pages(),
1,
"Muss genau 1 beschädigte Seite protokollieren"
);
// Wurzelverzeichnis '/' muss intakt sein
assert!(cfs_reloaded.resolve_path("/").is_some());
// Einträge der ersten, unbeschädigten Seite müssen weiterhin vollständig auffindbar sein
let alpha_0 = cfs_reloaded.resolve_path(&format!("/alpha_0000_{}", "a".repeat(800)));
assert!(
alpha_0.is_some(),
"Dateien aus der intakten Seite 0 müssen lesbar bleiben"
);
}
/// 4. Index-Konsistenztest (D-02): Operationsfolge gegen frisch aufgebauten Referenzindex abgleichen
#[tokio::test]
async fn test_children_index_consistency() {
let env = setup_carrier_env("children_consistency", 10);
let cfs = load_carrier_fs(&env);
// 1. Verzeichnisse anlegen
cfs.create_dir(&DavPath::new("/docs").unwrap())
.await
.unwrap();
cfs.create_dir(&DavPath::new("/docs/work").unwrap())
.await
.unwrap();
cfs.create_dir(&DavPath::new("/photos").unwrap())
.await
.unwrap();
// 2. Dateien anlegen
let opt = OpenOptions {
create_new: true,
write: true,
..Default::default()
};
let mut f1 = cfs
.open(&DavPath::new("/docs/report.txt").unwrap(), opt.clone())
.await
.unwrap();
f1.write_bytes(Bytes::from_static(b"report")).await.unwrap();
f1.flush().await.unwrap();
drop(f1);
let mut f2 = cfs
.open(&DavPath::new("/docs/work/notes.txt").unwrap(), opt.clone())
.await
.unwrap();
f2.write_bytes(Bytes::from_static(b"notes")).await.unwrap();
f2.flush().await.unwrap();
drop(f2);
let mut f3 = cfs
.open(&DavPath::new("/photos/pic.jpg").unwrap(), opt.clone())
.await
.unwrap();
f3.write_bytes(Bytes::from_static(b"pic")).await.unwrap();
f3.flush().await.unwrap();
drop(f3);
// 3. Verschieben/Umbenennen
cfs.rename(
&DavPath::new("/docs/work/notes.txt").unwrap(),
&DavPath::new("/photos/notes_moved.txt").unwrap(),
)
.await
.unwrap();
// 4. Datei löschen
cfs.remove_file(&DavPath::new("/docs/report.txt").unwrap())
.await
.unwrap();
// 5. Verzeichnis löschen
cfs.remove_dir(&DavPath::new("/docs/work").unwrap())
.await
.unwrap();
// 6. Abgleich: children_index gegen frisch aufgebauten Referenzindex prüfen
{
let inner = cfs.inner.lock().unwrap();
let reference_index = CarrierFsInner::build_children_index(&inner.manifest.inodes);
for (parent_id, ref_children) in &reference_index {
let actual_children = inner.children_index.get(parent_id);
assert!(
actual_children.is_some(),
"Parent {} fehlt im children_index",
parent_id
);
let mut ref_sorted = ref_children.clone();
ref_sorted.sort();
let mut act_sorted = actual_children.unwrap().clone();
act_sorted.sort();
assert_eq!(
ref_sorted, act_sorted,
"Inkonsistenz der Kinder für Parent {}",
parent_id
);
}
// Auch die Umkehrung prüfen (keine Geister-Einträge)
for (parent_id, actual_children) in &inner.children_index {
if actual_children.is_empty() {
continue;
}
assert!(
reference_index.contains_key(parent_id),
"children_index enthält verwaisten Parent {}",
parent_id
);
}
}
}
/// 5. Performance-Sanity-Check: Pfadauflösung bei 5.000 Geschwistern muss sub-millisekündlich sein (D-02)
#[tokio::test]
async fn test_secondary_index_path_resolution_speed() {
let env = setup_carrier_env("perf_resolution", 15);
let cfs = load_carrier_fs(&env);
cfs.create_dir(&DavPath::new("/big_dir").unwrap())
.await
.unwrap();
let big_dir_node = cfs.resolve_path("/big_dir").expect("big_dir node");
// 5.000 Kindknoten in /big_dir anlegen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..5_000 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let inode = CarrierInode {
id,
parent_id: Some(big_dir_node.id),
name: format!("child_{:04}.txt", i),
is_dir: false,
size: 10,
created_at: 3000,
modified_at: 3000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(big_dir_node.id, id);
}
inner.mark_dirty();
}
// Messung: Pfadauflösung für den letzten Eintrag
let start = Instant::now();
let target = cfs.resolve_path("/big_dir/child_4999.txt");
let elapsed = start.elapsed();
assert!(target.is_some());
assert_eq!(target.unwrap().name, "child_4999.txt");
// Der Sekundärindex muss den Eintrag in unter 5 Millisekunden finden (typisch < 0.2 ms)
assert!(
elapsed.as_millis() < 5,
"Pfadauflösung dauerte zu lange: {:?}",
elapsed
);
}
/// 6. Platzmangel-Migrationstest: Migration eines Containers ohne freie Blöcke bricht sauber ab (D-03)
#[tokio::test]
async fn test_out_of_space_migration_aborts_cleanly() {
let env = setup_carrier_env("migration_no_space", 5);
// V1-Manifest mit 0 freien Blöcken (free_blocks ist leer)
let mut v1_manifest = CarrierManifest {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION,
manifest_generation: 1,
total_blocks: 2,
free_blocks: Vec::new(), // Keine freien Blöcke!
next_inode_id: 2,
page_block_indices: Vec::new(),
inodes: HashMap::new(),
};
v1_manifest.inodes.insert(
1,
CarrierInode {
id: 1,
parent_id: None,
name: String::new(),
is_dir: true,
size: 0,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
let dek_inner = env.auth_hidden.dek();
write_carrier_block(
&env.db,
env.carrier_node_id,
0,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
write_carrier_block(
&env.db,
env.carrier_node_id,
1,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
let cfs = load_carrier_fs(&env);
// Versuch zu speichern muss mit klarer Fehlermeldung fehlschlagen
let res = {
let mut inner = cfs.inner.lock().unwrap();
inner.mark_dirty();
inner.save_manifest()
};
assert!(res.is_err());
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Nicht genügend freie Blöcke"),
"Fehlermeldung muss auf Speichermangel hinweisen: {}",
err_msg
);
drop(cfs);
// Trägerdatei muss weiterhin intakt als V1 ladbar sein
let cfs_recheck = load_carrier_fs(&env);
{
let inner = cfs_recheck.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION);
}
}
/// 7. Seitenfreigabe (D-05): Überzählige Seitenblöcke werden bei Schrumpfung geschreddert und freigegeben
#[tokio::test]
async fn test_page_block_reclaiming_on_shrink() {
let env = setup_carrier_env("page_reclaiming", 25);
let cfs = load_carrier_fs(&env);
let initial_free = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
// 1. Viele Inodes erzeugen, sodass mindestens 3 Seiten belegt werden
let mut added_ids = Vec::new();
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..2500 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
added_ids.push(id);
let inode = CarrierInode {
id,
parent_id: Some(1),
name: format!("large_item_{:04}_{}", i, "x".repeat(800)),
is_dir: false,
size: 10,
created_at: 4000,
modified_at: 4000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync 3 pages");
let pages_before = cfs.page_count();
assert!(
pages_before >= 3,
"Muss mindestens 3 Seiten belegen, hat: {}",
pages_before
);
let free_after_expand = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
assert_eq!(free_after_expand, initial_free - (pages_before - 1));
// 2. Fast alle Inodes entfernen, sodass nur noch 1 Seite benötigt wird
{
let mut inner = cfs.inner.lock().unwrap();
for id in added_ids {
inner.manifest.inodes.remove(&id);
inner.index_remove_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync after shrink");
// 3. Verifizieren: page_count ist wieder 1, freie Blöcke wurden zurückgegeben
let pages_after = cfs.page_count();
assert_eq!(
pages_after, 1,
"Nach Schrumpfung darf nur noch 1 Seite belegt sein"
);
let free_after_shrink = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
assert_eq!(
free_after_shrink, initial_free,
"Alle überzähligen Seitenblöcke müssen an free_blocks zurückgegeben worden sein"
);
drop(cfs);
// Trägerdatei neu laden und Integrität prüfen
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(cfs_reloaded.page_count(), 1);
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
assert!(cfs_reloaded.resolve_path("/").is_some());
}
+770 -133
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -20,7 +20,10 @@ use sanctum::{
#[tokio::test]
async fn test_live_crash_and_recovery_stress() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
let container_path: PathBuf = temp_dir.join(format!(
"sanctum_live_stress_{}.sanctum",
std::process::id()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -105,13 +108,17 @@ async fn test_live_crash_and_recovery_stress() {
// 4. Recovery & Integritätsprüfung nach Crash
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
let verify_result =
verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
assert!(
verify_result.is_healthy(),
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
verify_result.errors
);
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
assert_eq!(
verify_result.corrupted_chunks, 0,
"Keine korrupten Chunks erlaubt"
);
// 5. Konsistentes Weiterarbeiten nach dem Absturz
let db_recovered = Database::open(&container_path).expect("Open database after crash");
+232 -5
View File
@@ -1,10 +1,10 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::mount::is_loopback_host;
use sanctum::storage::Database;
use std::path::PathBuf;
#[test]
fn test_is_loopback_host_comprehensive() {
@@ -45,7 +45,8 @@ fn test_is_loopback_host_comprehensive() {
#[test]
fn test_wal_and_shm_cleanup_on_close() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -61,7 +62,8 @@ fn test_wal_and_shm_cleanup_on_close() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
let _ = db.create_node(1, "test_file.txt", false).unwrap();
@@ -77,8 +79,233 @@ fn test_wal_and_shm_cleanup_on_close() {
shm.push("-shm");
let _ = std::fs::remove_file(&shm);
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(
!PathBuf::from(wal).exists(),
"WAL-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
assert!(
!PathBuf::from(shm).exists(),
"SHM-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_mount_security_multi_auth_and_no_token_in_url() {
use sanctum::mount::{check_basic_auth, check_token_header, uri_contains_token};
let session_token = "4f8a12bc90de45f187a23456789abcde";
let port = 8443;
let remote_url = format!("http://127.0.0.1:{}/", port);
// R-05 & SA-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
assert!(
!remote_url.contains(session_token),
"Remote URL darf niemals das Session-Token enthalten"
);
// 1. Basic Auth Prüfung (Constant-Time)
use base64::Engine;
let auth_header = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))
);
assert!(check_basic_auth(&auth_header, session_token));
let wrong_auth = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("sanctum:wrong_token_1234567890abcdef")
);
assert!(!check_basic_auth(&wrong_auth, session_token));
// 2. Token Header Prüfung (Constant-Time)
let mut headers = hyper::HeaderMap::new();
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
assert!(check_token_header(&headers, session_token));
let mut wrong_headers = hyper::HeaderMap::new();
wrong_headers.insert(
"X-Sanctum-Token",
"wrong_token_1234567890abcdef".parse().unwrap(),
);
assert!(!check_token_header(&wrong_headers, session_token));
// 3. SA-05: Verifikation, dass Token in URI (Pfad oder Query) erkannt und strikt abgewiesen wird
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_path, session_token),
"Token im Pfad muss erkannt werden"
);
let uri_query: hyper::Uri = format!("http://127.0.0.1:8443/test.txt?token={}", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_query, session_token),
"Token im Query-String muss erkannt werden"
);
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/test.txt".parse().unwrap();
assert!(
!uri_contains_token(&uri_clean, session_token),
"Saubere URI darf kein Token enthalten"
);
}
#[tokio::test]
async fn test_vfs_memory_lock_retention_on_clone_v03() {
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, ReadDirMeta};
use futures_util::StreamExt;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_v03_lock_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassV03!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// 1. Initialisiere SanctumFs
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
assert_eq!(
fs.dek_strong_count(),
1,
"Anfangs muss genau 1 Referenz auf den verriegelten DEK existieren"
);
// 2. Klone SanctumFs (wie bei jeder HTTP/WebDAV-Anfrage im Server)
let fs_clone = fs.clone();
assert_eq!(
fs.dek_strong_count(),
2,
"Nach dem Klonen müssen 2 Referenzen existieren"
);
// 3. Droppe den Klon
drop(fs_clone);
// 4. V-03: Der Refcount muss nun wieder 1 sein. Der Speicherbereich darf NICHT
// vorzeitig über Drop eines Klons entriegelt worden sein!
assert_eq!(
fs.dek_strong_count(),
1,
"Nach Drop des Klons muss genau 1 Referenz erhalten bleiben"
);
// Verifiziere funktionale Nutzbarkeit nach Drop des Klons
let root_path = DavPath::new("/").unwrap();
let mut entries = fs.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
assert!(entries.next().await.is_none());
drop(fs);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_z01_decoy_password_zeroize_memory() {
use sanctum::mount::ContainerAuth;
use zeroize::Zeroizing;
// Test that ContainerAuth properly encapsulates Zeroizing credentials
let raw_pass = "TopSecretDecoyPass2026!".to_string();
let zeroized_pass = Zeroizing::new(raw_pass.clone());
let auth = ContainerAuth::Password(zeroized_pass.clone());
if let ContainerAuth::Password(ref p) = auth {
assert_eq!(p.as_str(), raw_pass.as_str());
} else {
panic!("ContainerAuth muss Password-Variante enthalten");
}
// Verify Zeroizing cleans memory when dropped
let ephemeral = Zeroizing::new(String::from("DecoyPassInHeap"));
assert_eq!(&*ephemeral, "DecoyPassInHeap");
// Explicit drop calls zeroize
drop(ephemeral);
}
#[tokio::test]
async fn test_z04_webdav_quota_report() {
use dav_server::fs::DavFileSystem;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_z04_quota_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassZ04!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Erstelle einen Testknoten mit Chunks
let node = db.create_node(1, "payload.bin", false).unwrap();
let dummy_chunk = vec![0xAAu8; 1024 * 1024]; // 1 MiB
db.write_chunk_and_update_size(
node.id,
0,
0,
&[0u8; 12],
&[0u8; 16],
&dummy_chunk,
1024 * 1024,
123456,
)
.unwrap();
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
// Rufe WebDAV get_quota ab
let (used, total_opt) = fs.get_quota().await.unwrap();
assert!(
used > 0,
"Z-04: Verwendeter Speicherplatz muss > 0 sein (gemeldet: {} Bytes)",
used
);
assert!(
total_opt.is_some(),
"Z-04: Gesamtkapazität muss gemeldet werden"
);
let total = total_opt.unwrap();
assert!(
total >= used,
"Z-04: Gesamtkapazität ({}) muss mindestens dem belegten Speicher ({}) entsprechen",
total,
used
);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
+71 -4
View File
@@ -1,4 +1,4 @@
use sanctum::sync::validate_node_name;
use sanctum::sync::validate_node_name;
#[test]
fn test_validate_node_name_rejections() {
@@ -27,9 +27,25 @@ fn test_validate_node_name_rejections() {
// 6. Windows reservierte Gerätenamen
let reserved_names = [
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
"CON",
"con",
"prn",
"PRN",
"aux",
"AUX",
"nul",
"NUL",
"COM1",
"com2",
"COM9",
"lpt1",
"LPT2",
"LPT9",
"con.txt",
"aux.dat",
"NUL.tar.gz",
"com1.log",
"prn.pdf",
];
for res in reserved_names {
assert!(
@@ -60,3 +76,54 @@ fn test_validate_node_name_accepted() {
);
}
}
#[test]
fn test_storage_create_and_rename_reject_invalid_names() {
let mut path = std::env::temp_dir();
path.push(format!(
"sanctum_test_pathutil_{}.sanctum",
std::process::id()
));
if path.exists() {
let _ = std::fs::remove_file(&path);
}
let salt = sanctum::crypto::generate_salt();
let kdf_params = sanctum::crypto::KdfParams {
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
time_cost: sanctum::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = sanctum::crypto::derive_kek("Pass1234!", &salt, &kdf_params).unwrap();
let dek = sanctum::crypto::generate_dek();
let (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
let db = sanctum::storage::Database::open(&path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag)
.unwrap();
// create_node_in_vault mit ungültigem Namen muss scheitern
assert!(db.create_node_in_vault(0, 1, "..", false, &dek).is_err());
assert!(db.create_node_in_vault(0, 1, "CON", false, &dek).is_err());
assert!(db
.create_node_in_vault(0, 1, "sub/dir", false, &dek)
.is_err());
// Gültige Datei erstellen
let valid_node = db
.create_node_in_vault(0, 1, "valid.txt", false, &dek)
.unwrap();
// rename_node_in_vault mit ungültigem Namen muss scheitern
assert!(db
.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek)
.is_err());
let _ = std::fs::remove_file(&path);
}
+133 -14
View File
@@ -1,11 +1,11 @@
use std::collections::HashSet;
use std::path::PathBuf;
use rand::rngs::OsRng;
use rand::RngCore;
use std::collections::HashSet;
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
@@ -41,8 +41,7 @@ fn test_carrier_protection_in_storage_and_sync() {
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
@@ -74,22 +73,38 @@ fn test_carrier_protection_in_storage_and_sync() {
// 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
let del_res = db.delete_node(carrier_node_id);
assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen");
assert!(
del_res.is_err(),
"Löschen der Trägerdatei muss fehlschlagen"
);
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0);
assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen");
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
rename_res.is_err(),
"Umbenennen der Trägerdatei muss fehlschlagen"
);
assert!(rename_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
trunc_res.is_err(),
"Truncate der Trägerdatei muss fehlschlagen"
);
assert!(trunc_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
// Der Carrier darf unter keinen Umständen gelöscht werden!
let local_paths = HashSet::new();
let excluded_paths = HashSet::new();
let mut stats = SyncStats::default();
let orphan_res = delete_orphans_in_vault(
&db,
@@ -98,16 +113,120 @@ fn test_carrier_protection_in_storage_and_sync() {
1, // Root-Knoten
"",
&local_paths,
&excluded_paths,
&[],
false, // delete_excluded: false
false, // kein dry_run
true, // quiet
&mut stats,
);
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
assert!(
orphan_res.is_ok(),
"delete_orphans_in_vault muss ohne Fehler durchlaufen"
);
assert_eq!(
stats.files_deleted, 0,
"Carrier-Datei darf nicht gelöscht worden sein"
);
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
assert!(
carrier_rec.is_some(),
"Carrier-Knoten muss nach Sync unversehrt vorhanden sein"
);
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
let carrier_node = db.get_node_by_id(carrier_node_id).unwrap().unwrap();
let local_dest_file =
std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
let pull_single_res = sanctum::sync::sync_single_file_to_host(
&db,
0,
&dek_0,
sanctum::crypto::FORMAT_VERSION,
&carrier_node,
&local_dest_file,
false,
false,
);
assert!(
pull_single_res.is_err(),
"sync_single_file_to_host auf Carrier muss fehlschlagen"
);
assert!(
!local_dest_file.exists(),
"Trägerdatei darf niemals auf den Host geschrieben werden"
);
// 6. R-02: run_sync mit Pull muss die Trägerdatei überspringen
let local_pull_dir =
std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
let sync_options = sanctum::sync::SyncOptions {
direction: sanctum::sync::SyncDirection::Pull,
delete: false,
delete_excluded: false,
dry_run: false,
checksum: false,
exclude_patterns: Vec::new(),
quiet: true,
force: false,
backup: false,
update: false,
};
let pull_res = sanctum::sync::run_sync(
&db,
0,
&dek_0,
sanctum::crypto::FORMAT_VERSION,
"/",
&local_pull_dir.to_string_lossy(),
&sync_options,
);
assert!(
pull_res.is_ok(),
"run_sync pull muss erfolgreich durchlaufen"
);
let pulled_carrier = local_pull_dir.join(carrier_name);
assert!(
!pulled_carrier.exists(),
"Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden"
);
let _ = std::fs::remove_dir_all(&local_pull_dir);
// 7. R-02: VFS Copy Schutz
let fs = sanctum::vfs::SanctumFs::with_carrier(
db.clone(),
dek_0.clone(),
Some(dek_0.clone()),
Some(carrier_node_id),
sanctum::crypto::FORMAT_VERSION,
false,
0,
);
use dav_server::davpath::DavPath;
use dav_server::fs::DavFileSystem;
// Copy Quelle = Carrier
let from_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
let to_copy = DavPath::new("/carrier_copy.iso").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
assert!(
copy_src_res.is_err(),
"SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern"
);
// Copy Ziel = Carrier
let _regular_file = db
.create_node_in_vault(0, 1, "regular.txt", false, &dek_0)
.unwrap();
let from_regular = DavPath::new("/regular.txt").unwrap();
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
assert!(
copy_dst_res.is_err(),
"SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern"
);
let _ = std::fs::remove_file(&path);
}
+966 -18
View File
File diff suppressed because it is too large Load Diff
+792 -11
View File
@@ -1,9 +1,10 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use std::path::PathBuf;
#[test]
fn test_reject_arbitrary_sqlite_database() {
@@ -16,12 +17,10 @@ fn test_reject_arbitrary_sqlite_database() {
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", [])
.unwrap();
conn.execute("INSERT INTO users (name) VALUES ('Alice')", [])
.unwrap();
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
}
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
@@ -114,7 +113,8 @@ fn test_reject_out_of_bounds_kdf_params() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -169,7 +169,8 @@ fn test_reject_out_of_bounds_kdf_params() {
#[test]
fn test_reject_corrupted_slot_lengths() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
let db_path: PathBuf =
temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
@@ -185,7 +186,8 @@ fn test_reject_corrupted_slot_lengths() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -214,3 +216,782 @@ fn test_reject_corrupted_slot_lengths() {
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_reject_slot_amplification_and_excessive_slots_dos() {
let temp_dir = std::env::temp_dir();
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
// 1. Fall: 0 Slots (leere meta-Tabelle)
{
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
.unwrap();
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err());
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("leer oder beschädigt"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
{
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
.unwrap();
}
let db_reopened = Database::open(&db_path).unwrap();
let slots = db_reopened
.read_slots()
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].slot_id, 0);
let _ = std::fs::remove_file(&db_path);
}
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
{
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let slots = db
.read_slots()
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].slot_id, 0);
assert_eq!(slots[1].slot_id, 1);
let _ = std::fs::remove_file(&db_path);
}
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
{
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
for i in 0..3 {
conn.execute(
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"Database::open muss 3 Slots sofort abweisen"
);
let err_msg = open_res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-Anzahl"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
{
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
for i in 0..100 {
conn.execute(
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"100 Slots müssen sofort abgewiesen werden"
);
let _ = std::fs::remove_file(&db_path);
}
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
{
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
let mut stmt = conn
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
.unwrap();
for i in 0..1000 {
stmt.execute(rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
])
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"1000 Slots müssen sofort abgewiesen werden"
);
let err_msg = open_res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-Anzahl"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
}
#[test]
fn test_reject_duplicate_and_invalid_slot_ids() {
let temp_dir = std::env::temp_dir();
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
{
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Doppelte Slot-ID 0"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
{
let db_path = temp_dir.join(format!(
"test_invalid_slot_id_{}.sanctum",
std::process::id()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-ID 5"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
{
let db_path = temp_dir.join(format!(
"test_missing_slot_0_{}.sanctum",
std::process::id()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
}
#[test]
fn test_container_meta_authenticate_defense_in_depth() {
use sanctum::storage::{ContainerMeta, SlotMeta};
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let valid_slot_0 = SlotMeta {
slot_id: 0,
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
};
let valid_slot_1 = SlotMeta {
slot_id: 1,
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
};
// A. Leere Slots -> sofort None
let meta_empty = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![],
};
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
let meta_3_slots = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![
valid_slot_0.clone(),
valid_slot_1.clone(),
valid_slot_0.clone(),
],
};
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
let meta_no_slot0 = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_1.clone()],
};
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
// D. Ungültige Slot-ID (> 1) -> sofort None
let mut invalid_slot = valid_slot_0.clone();
invalid_slot.slot_id = 99;
let meta_invalid_id = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_0.clone(), invalid_slot],
};
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
let meta_valid_1 = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_0.clone()],
};
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
}
#[test]
fn test_k01_metadata_tampering_detected_by_verify() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "SecretMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
let db = Database::open(&db_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
.unwrap();
// Knoten anlegen
let node = db.create_node(1, "secret_financials.xlsx", false).unwrap();
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
db.checkpoint().unwrap();
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
report.errors
);
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
[],
)
.unwrap();
}
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_report.is_healthy(),
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_report.errors
);
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
[node.id],
)
.unwrap();
}
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_name_report.is_healthy(),
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_name_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_name_report.errors
);
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_k01_upgrade_format_v2_to_v3() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf =
temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "UpgradeMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
// Erzeuge bewusst einen V2-Container ohne metadata_mac
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE meta (
slot_id INTEGER NOT NULL PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index)
);",
)
.unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
sanctum::crypto::MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped_dek,
nonce.as_slice(),
tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
[],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let meta_before = db.read_meta().unwrap();
assert_eq!(meta_before.version, 2);
// Upgrade auf Format V3 durchführen
db.upgrade_to_v3(&dek).unwrap();
db.checkpoint().unwrap();
let meta_after = db.read_meta().unwrap();
assert_eq!(meta_after.version, 3);
// V3-Container muss nach dem Upgrade integer und gesund sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
report.errors
);
let _ = std::fs::remove_file(&db_path);
}
#[tokio::test]
async fn test_k02_chunk_replay_detected_by_vfs_and_crypto() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join(format!(
"k02_replay_test_{}.sanctum",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let password = "TestPasswordK02!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// 1. Datei im Format V3 anlegen
let file = db.create_node(1, "replay_target.txt", false).unwrap();
let initial_data = b"State 1: Initial secret content in chunk 0.";
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
let (ct1, nonce1, tag1) = encrypt_chunk(
&dek,
file.id,
0,
initial_data,
sanctum::crypto::FORMAT_VERSION_V3,
gen1,
)
.unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen1,
&nonce1,
&tag1,
&ct1,
initial_data.len() as u64,
1000,
)
.unwrap();
db.checkpoint().unwrap();
// Ciphertext-Zeile von Zustand 1 sichern (Nonce, Tag, Ciphertext)
let saved_chunk1 = db.read_chunk(file.id, 0).unwrap().unwrap();
assert_eq!(saved_chunk1.generation, gen1);
// 2. Chunk mit neuem Inhalt überschreiben (Zustand 2)
let updated_data = b"State 2: Updated overwritten content in chunk 0.";
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
assert!(gen2 > gen1, "Generation muss monoton steigen");
let (ct2, nonce2, tag2) = encrypt_chunk(
&dek,
file.id,
0,
updated_data,
sanctum::crypto::FORMAT_VERSION_V3,
gen2,
)
.unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen2,
&nonce2,
&tag2,
&ct2,
updated_data.len() as u64,
2000,
)
.unwrap();
db.checkpoint().unwrap();
let current_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
assert_eq!(current_chunk.generation, gen2);
// 3. Replay-Angriff: Angreifer spielt alte Ciphertext-Zeile von Zustand 1 zurück in die SQLite-Tabelle
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = 0",
rusqlite::params![
saved_chunk1.nonce.as_slice(),
saved_chunk1.tag.as_slice(),
saved_chunk1.ciphertext,
file.id,
],
)
.unwrap();
}
// Entschlüsselungsversuch muss fehlschlagen (AEAD Auth-Fehler wegen AAD-Generationsabweichung)
let replayed_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
let decrypt_res = decrypt_chunk(
&dek,
file.id,
0,
&replayed_chunk.ciphertext,
&replayed_chunk.nonce,
&replayed_chunk.tag,
sanctum::crypto::FORMAT_VERSION_V3,
replayed_chunk.generation,
);
assert!(
decrypt_res.is_err(),
"K-02: Replay von altem Ciphertext in aktuellem Chunk-Slot muss durch AEAD AAD-Mismatch abgewiesen werden!"
);
// Verify muss Replay/Manipulierte Chunks erkennen
let verify_res = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!verify_res.is_healthy() || verify_res.corrupted_chunks > 0,
"Verify muss Replay/Manipulierte Chunks erkennen"
);
let _ = std::fs::remove_file(&db_path);
}
+135 -6
View File
@@ -1,5 +1,7 @@
use sanctum::upgrade::{
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
use sanctum::upgrade::{
run_upgrade, validate_trusted_comment_version, verify_minisign_signature,
verify_release_manifest_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
SANCTUM_RELEASE_PUBKEY_BACKUP,
};
#[test]
@@ -12,7 +14,11 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
assert!(
res.is_ok(),
"Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}",
res.err()
);
}
#[test]
@@ -25,7 +31,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
assert!(
res.is_err(),
"Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen"
);
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
}
@@ -41,7 +50,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
// Ein anderer, unautorisierter Public Key
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
let res = verify_minisign_signature(payload, sig_text, other_key);
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
assert!(
res.is_err(),
"Signaturprüfung mit fremdem Schlüssel muss fehlschlagen"
);
}
#[test]
@@ -55,10 +67,127 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
};
let res = run_upgrade(&options);
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
assert!(
res.is_err(),
"Inoffizielle Server-URL ohne --insecure-url muss blockiert werden"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
"Fehler muss Sicherheitsverstoß anzeigen: {err_msg}"
);
}
#[test]
fn test_u01_reject_legacy_unhashed_signature() {
let payload = b"Hello Sanctum Release 0.7.0 Security Testing\n";
let sig_text = "\
untrusted comment: signature from minisign secret key\n\
RUSsVphPgr8155LCucDq8RzDmWMzWElzhBmHrS5p+qJlOCGU4AQpn2F28MVPFrnoGPYEHx7/6TlKg2b45DlYuBQ0+ASeLIzMLwc=\n\
trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
// Gültige pre-hashed Signatur muss weiterhin erfolgreich geprüft werden
assert!(verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY).is_ok());
// Modifiziere die Signatur, um eine Legacy-Signatur (Ed statt ED) zu simulieren
// In Base64 entspricht 'ED' (0x45, 0x44) dem Präfix 'RU'
// 'Ed' (0x45, 0x64) entspricht in Base64 dem Präfix 'RW'
let lines: Vec<&str> = sig_text.lines().collect();
let mut sig_line = lines[1].to_string();
assert!(sig_line.starts_with("RU"));
sig_line.replace_range(0..2, "RW");
let legacy_sig_text = format!("{}\n{}\n{}\n{}\n", lines[0], sig_line, lines[2], lines[3]);
let res = verify_minisign_signature(payload, &legacy_sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(
res.is_err(),
"U-01: Legacy-Signaturen (ohne Pre-Hashing) müssen strikt abgewiesen werden"
);
}
#[test]
fn test_u02_validate_trusted_comment_version_matching() {
// Standard Minisign format with -t "version:0.8.0"
let tc1 = "timestamp:1789750897\tversion:0.8.0\tfile:SHA256SUMS.txt\thashed";
assert!(validate_trusted_comment_version(tc1, "0.8.0").is_ok());
assert!(validate_trusted_comment_version(tc1, "v0.8.0").is_ok());
// Alternative tags like tag:v0.8.0 or release:0.8.0
let tc2 = "release:0.8.0";
assert!(validate_trusted_comment_version(tc2, "0.8.0").is_ok());
let tc3 = "tag:v0.8.0";
assert!(validate_trusted_comment_version(tc3, "0.8.0").is_ok());
}
#[test]
fn test_u02_reject_mismatched_release_tag_replay() {
// An attacker replays a valid signature from v0.7.0 for an update claiming to be v0.8.0
let tc_old = "timestamp:1789750897\tversion:0.7.0\tfile:SHA256SUMS.txt\thashed";
let res = validate_trusted_comment_version(tc_old, "v0.8.0");
assert!(
res.is_err(),
"U-02: Ältere oder abweichende Version im Trusted Comment muss abgewiesen werden"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("U-02") && err_msg.contains("Replay-Angriff"),
"Fehlermeldung muss auf Replay-Angriff und U-02 hinweisen: {err_msg}"
);
// Empty or completely missing version token
let tc_no_ver = "timestamp:1789750897\tfile:SHA256SUMS.txt\thashed";
let res2 = validate_trusted_comment_version(tc_no_ver, "0.8.0");
assert!(res2.is_err());
}
#[test]
fn test_u03_verify_signature_with_backup_key_fallback() {
let payload = b"Hello Sanctum Release 0.8.0 Backup Key Testing\n";
let backup_sig = "\
untrusted comment: signature from minisign secret key\n\
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
trusted comment: version:0.8.0\n\
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
// 1. Direct verification against primary key MUST fail (key rotation scenario)
let prim_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY);
assert!(
prim_res.is_err(),
"Backup-Signatur darf nicht gegen den Primärschlüssel verifizieren"
);
// 2. Direct verification against backup key MUST succeed
let back_res = verify_minisign_signature(payload, backup_sig, SANCTUM_RELEASE_PUBKEY_BACKUP);
assert!(
back_res.is_ok(),
"Backup-Signatur muss gegen SANCTUM_RELEASE_PUBKEY_BACKUP gültig sein: {:?}",
back_res.err()
);
// 3. Fallback verification via verify_release_manifest_signature MUST succeed transparently
let fallback_res = verify_release_manifest_signature(payload, backup_sig);
assert!(
fallback_res.is_ok(),
"verify_release_manifest_signature muss via Backup-Schlüssel erfolgreich sein"
);
assert_eq!(fallback_res.unwrap(), "version:0.8.0");
}
#[test]
fn test_u03_verify_signature_rejects_untrusted_third_party_key() {
let fake_sig = "\
untrusted comment: signature from minisign secret key\n\
RUQEeUSLlX2p4HrXDO2TYykyNK0C41Vdrq6MIts14mQgNDClGC2RkBV3jHRbP7IclWv+h/IuzvyYi9yz8/RqfhpDVwhqtvC02gk=\n\
trusted comment: version:0.8.0\n\
zjZAS4uy1PH+0S9KQ0EU435lpBczU3lULyS5SkWf093iCEBc1ULiscwE5gWkEkex3ecPdRugopT+fKaZBQQnBg==\n";
// Tampered payload with neither primary nor backup key matching
let tampered = b"Tampered data not signed by either key";
let res = verify_release_manifest_signature(tampered, fake_sig);
assert!(
res.is_err(),
"Weder Primär- noch Backup-Schlüssel dürfen manipulierte Nutzdaten akzeptieren"
);
}
+2 -1
View File
@@ -150,7 +150,8 @@ fn test_json_deserialization_of_gitea_release() {
]
}"##;
let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
let release: GiteaRelease =
serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
assert_eq!(release.id, 27);
assert_eq!(release.tag_name, "v0.5.0");
assert_eq!(release.assets.len(), 3);