Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb28c1c45 | ||
|
|
a252393f96 | ||
|
|
3693e03494 | ||
|
|
167e4b65fc | ||
|
|
02f731f57d | ||
|
|
728ac008ae | ||
|
|
8ff426d1b0 | ||
|
|
efddc2c477 | ||
|
|
dd6b6ff94d | ||
|
|
c21d63ad28 | ||
|
|
c606fa8132 | ||
|
|
8455dc02db | ||
|
|
ca6d54d5ca | ||
|
|
84b6a39f3f | ||
|
|
28a4bb6b70 | ||
|
|
45572b7370 | ||
|
|
17908a64fe | ||
|
|
487f999a24 | ||
|
|
5a1fd7cdd8 | ||
|
|
07afe34ad4 | ||
|
|
bb86447039 | ||
|
|
8dd54494b0 | ||
|
|
78a9c233f8 | ||
|
|
c7c0ed85f5 | ||
|
|
92db21d47e | ||
|
|
15adf79f48 | ||
|
|
1ba67cc9e2 | ||
|
|
708da24d91 | ||
|
|
730393423b | ||
|
|
e300a764c9 | ||
|
|
17ac4f9358 | ||
|
|
a2c16eda72 | ||
|
|
e496c418c5 | ||
|
|
2af14eef36 | ||
|
|
ad9148d867 | ||
|
|
a502845662 | ||
|
|
52859623c2 | ||
|
|
f9733dfc73 | ||
|
|
9634beb744 | ||
|
|
f557a9ddfa | ||
|
|
888c86ca5e | ||
|
|
7b0f061221 | ||
|
|
bdddd812ac | ||
|
|
36a4094336 | ||
|
|
ad531d8393 | ||
|
|
151fdba09d | ||
|
|
533764487a | ||
|
|
374150e5b3 | ||
|
|
fba7f305e3 | ||
|
|
1cdb30147b | ||
|
|
436790abf0 | ||
|
|
80a3bd1911 | ||
|
|
2da1ea55a9 | ||
|
|
2e4cf1cef0 | ||
|
|
1048253497 | ||
|
|
aa65d96434 | ||
|
|
3f3f8f2730 | ||
|
|
650cfc7cd4 | ||
|
|
9d1a4d3ad7 | ||
|
|
aa25719c0d | ||
|
|
34378998c4 | ||
|
|
4ef0c414fa | ||
|
|
1f3afb1e52 | ||
|
|
5f2040f8bf | ||
|
|
da2ddbd6ab | ||
|
|
46f976b353 | ||
|
|
6cb48f55d9 | ||
|
|
7ed0f51fb1 | ||
|
|
dae0b3b7c3 | ||
|
|
541190cff4 | ||
|
|
99813ae2a3 | ||
|
|
90cf4927af | ||
|
|
abf395627a | ||
|
|
fcd59dfe68 | ||
|
|
a8fdfc674e | ||
|
|
70d03df676 | ||
|
|
771c08ff0f | ||
|
|
98bfac718f | ||
|
|
030ce6a1e5 | ||
|
|
b8e4dcb614 | ||
|
|
ea571d245e | ||
|
|
bb4268cb62 | ||
|
|
952e0cb23a | ||
|
|
fb154c0d2e | ||
|
|
5c1ac89989 | ||
|
|
1e6854e9f5 | ||
|
|
2cb065c8c0 | ||
|
|
38df3d3845 | ||
|
|
1c8a860184 | ||
|
|
2d14c64c3e | ||
|
|
a9c3dd25a3 | ||
|
|
58336fc059 | ||
|
|
83dc42dd86 | ||
|
|
38a8a747c7 | ||
|
|
38c0a6dc12 | ||
|
|
d15ebd8916 |
@@ -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
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
/dist
|
||||
*.sanctum
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
*.token
|
||||
.token
|
||||
*.key
|
||||
sanctum-release.key
|
||||
|
||||
+405
-1
@@ -5,7 +5,411 @@ 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/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.9.1] - 2026-09-20
|
||||
|
||||
### Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
|
||||
Dieses Release behebt die im Prüfbericht für v0.9.0 identifizierten vier Minor Issues bezüglich Metadaten-Authentifizierung beim Mounten, strikter Eingabevalidierung von Dateinamen im Carrier-Dateisystem, Dokumentation der Carrier-V1-nach-V2-Migration sowie Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung.
|
||||
|
||||
#### Behobene Minor Issues
|
||||
- **K-01 Metadaten-MAC-Validierung beim Mount**:
|
||||
- `mount_container` validiert vor dem Start des WebDAV-Dienstes die kryptografische HMAC-SHA-256 Integrität aller Metadaten für Container ab Format V3.
|
||||
- Registrierung des aktiven DEK (`set_active_slot_and_dek`), sodass fortlaufende Dateioperationen den Metadaten-MAC synchron halten.
|
||||
- **Lückenlose Pfadvalidierung (`validate_node_name`)**:
|
||||
- Konsequente Prüfung aller Dateinamen im Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`).
|
||||
- Zuverlässige Abweisung von Windows-reservierten Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`), ungültigen Zeichen (`<>:"/\|?*`), Steuerzeichen und Pfad-Traversal.
|
||||
- **Sicherheitsdokumentation & Runtime-Warnung für `--legacy-names`**:
|
||||
- Deutliche Warnung im CLI-Hilfetext, auf `stderr` und im Logging vor den Risiken von Swap-Attacks bei deaktivierter AAD-Bindung.
|
||||
- Aufnahme einer Risikobewertung in `README.md` und `THREAT_MODEL.md` (Abschnitt 7).
|
||||
- **Migration-Leitfaden (V1 → V2)**:
|
||||
- Schritt-für-Schritt-Anleitung in `README.md` (Abschnitt 10) zur transparenten Aktualisierung bestehender V1-Container auf das Paged-Manifest-Format V2.
|
||||
|
||||
## [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.500–7.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.
|
||||
- Eine ausführliche Schritt-für-Schritt-Anleitung zur Migration findet sich in der [README.md](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
|
||||
- **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
|
||||
Dieses Release behebt alle 11 identifizierten Sicherheitsprobleme (S-01 bis S-11) aus dem Sicherheits-Review von v0.6.0 umfassend und implementiert ein striktes Fail-Closed-Design.
|
||||
|
||||
#### Security & Architecture
|
||||
- **S-01: Ehrliche Neuklassifizierung des Dual-Vault (Second Safe)**:
|
||||
- Beseitigung aller unhaltbaren Behauptungen über "Plausible Deniability", "RIPA-49-Konformität" und "mathematische Ununterscheidbarkeit".
|
||||
- Klarstellung in Dokumentation und CLI: Das Dual-Vault-Design (Modell A mit Alibi-Carrier) bietet Schutz vor Schulterblick und erzwungener Herausgabe im Alltag, ist jedoch durch forensische Entropieanalyse der Trägerdatei detektierbar und stellt keinen rechtlichen Schutz gegen Beschlagnahme dar.
|
||||
- **S-02: Minisign-Signaturprüfung & Härtung des Update-Mechanismus**:
|
||||
- Integration von `minisign-verify` zur kryptografischen Prüfung der `SHA256SUMS.txt.minisig` gegen den offiziellen Ed25519 Release-Schlüssel (`RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU`) vor jedem Binary-Austausch.
|
||||
- Sichere Erstellung von Zwischendateien mittels `tempfile::Builder` mit restriktiven Dateirechten und RAII-Bereinigung im Fehlerfall.
|
||||
- Sperrung von Drittanbieter-URLs: Inoffizielle Server erfordern zwingend das Flag `--insecure-url` und eine interaktive Benutzerbestätigung (nicht durch `--yes` umgehbar).
|
||||
- **S-03: Carrier-Schutz vor Beschädigung und Korruption**:
|
||||
- Einführung der Datenbankspalte `is_carrier INTEGER NOT NULL DEFAULT 0` in der `nodes`-Tabelle zur präzisen Identifikation der Alibi-Trägerdatei ohne False Positives auf Benutzerdateien.
|
||||
- Fail-Closed Schutzschranke `assert_not_carrier`: Löschung (`delete_node`), Kürzung (`truncate_chunks_after`), Umbenennung (`rename_node_in_vault`) oder VFS-Überschreibung der Trägerdatei sowie das Löschen enthaltender Ordner wird strikt verboten.
|
||||
- Schutz der Trägerdatei bei `sanctum sync`: Die Alibi-Datei wird bei `--delete` (`delete_orphans_in_vault`) niemals entfernt.
|
||||
- **S-04: Schutz vor nicht vertrauenswürdigen Containern & KDF-DoS**:
|
||||
- `Database::open` prüft vor Ausführung von Pragmas, VACUUM oder Schema-Upgrades, ob fremde SQLite-Datenbanken vorliegen und lehnt diese sofort fail-closed ab.
|
||||
- Strikte Bereichsprüfung aller KDF-Parameter (`validate_kdf_params`): Memory Cost 19 MiB bis 256 MiB, Time Cost 2 bis 8, Parallelität 1 bis 8.
|
||||
- Strikte Validierung aller Header-Slot-Feldlängen (Salt 16B, Nonce 12B, Tag 16B, Wrapped DEK 40B/32B in Slot 0, 72B/64B/32B in Slot 1).
|
||||
- **S-05: Eliminierung unkontrollierter Panics in der Kryptografie**:
|
||||
- Beseitigung aller potenziellen Panics in `encrypt_node_name` und `decrypt_chunk` (LZ4-Größenvalidierung und unkomprimierte Chunks geben nun kontrollierte `Result`-Fehler zurück).
|
||||
- Umstellung des Release-Profils in `Cargo.toml` auf `panic = "unwind"` zur Vermeidung von Process-Aborts bei unerwarteten Fehlern.
|
||||
- **S-06: WebDAV-Loopback-Härtung & Unmount-Bereinigung**:
|
||||
- Robuste Host-Header-Prüfung in `is_loopback_host` (exakter Abgleich gegen `127.0.0.1`, `localhost`, `[::1]`, `::1` samt Port-Validierung) zur Abwehr von DNS-Rebinding- und Host-Header-Injection-Angriffen.
|
||||
- Beseitigung der Session-Token-Ausgabe im Terminal.
|
||||
- Zwingende Überprüfung und Löschung von temporären SQLite `-wal` und `-shm` Dateien nach Aushängen des Containers.
|
||||
- **S-07: Härtung der KDF-Parameter & Passwort-Validierung**:
|
||||
- Anhebung der KDF-Untergrenzen auf RFC 9106 Mindeststandards (Memory 19.456 KiB, Time Cost 2).
|
||||
- Mindestpasswortlänge von 12 Zeichen bei `init` und `passwd`.
|
||||
- Verbot identischer Passwörter oder Präfix-Kollisionen zwischen Standard- und Hidden-Vault.
|
||||
- **S-08: Path-Traversal-Schutz & Dateinamen-Validierung**:
|
||||
- Umfassende Dateinamenprüfung `validate_node_name` in `sanctum sync` und VFS: Verbot von Pfadtrennern (`/`, `\`), Null-Bytes, Steuerzeichen (< 0x20), `.` / `..` sowie reservierten Windows-Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
||||
- Strenge Pfadausbruchsprüfung bei `sync pull`.
|
||||
- **S-09: Sichere Einlesung von Wiederherstellungsschlüsseln**:
|
||||
- CLI-String-Übergabe von 24-Wort Notfallschlüsseln via `--recovery-key <PHRASE>` ist veraltet (Deprecation Warning).
|
||||
- Unterstützung für interaktive maskierte Eingabe (kein History-Leak), Stdin (`--recovery-key -`) und Umgebungsvariable `SANCTUM_RECOVERY_KEY`.
|
||||
- **S-10: SHA-256 Checksummenprüfung in Sync & WAL-Sicherheit**:
|
||||
- Echte SHA-256 Hash-Vergleiche bei `sanctum sync --checksum`.
|
||||
- Atomare SQLite-Transaktionen und garantierte WAL-Bereinigung.
|
||||
- **S-11: Sichere Trennung bei Dateinamen-Entschlüsselung**:
|
||||
- Unsicherer statischer Fallback-Schlüssel (`b"SANCTUM_NODE_NAME"`) ist standardmäßig deaktiviert.
|
||||
- Abwärtskompatibilität für Alttresore nur noch bei expliziter Angabe des globalen CLI-Flags `--legacy-names`.
|
||||
|
||||
## [0.6.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
- **`sanctum upgrade` (Alias: `sanctum update`) - In-Place Self-Upgrade Engine**:
|
||||
- Ermöglicht das direkte Aktualisieren der laufenden Sanctum-Binary ohne externe Paketmanager oder manuelle Download-Schritte.
|
||||
- **Plattformunabhängiger In-Place-Austausch**: Löst das Windows-Dateilock-Problem (`ERROR_ACCESS_DENIED`) via `self-replace` (Umbenennen der laufenden Executable und verzögerte Bereinigung). Auf Linux/Unix wird ein sauberer atomarer Tausch mit Ausführungsrechten (`0o755`) durchgeführt.
|
||||
- **Kryptografische Integritätsprüfung**: Vor dem Austausch wird jedes heruntergeladene Binary zwingend gegen die offizielle `SHA256SUMS.txt` des Gitea-Releases via SHA-256 verifiziert. Bei Abweichungen bleibt die bestehende Installation vollständig unberührt.
|
||||
- **Prüfmodus (`--check` / `-c`)**: Schneller Abgleich mit der Gitea-Releases-API zur Feststellung, ob ein Update vorliegt, ohne Downloads durchzuführen.
|
||||
- **Automatisierungsmodus (`--yes` / `-y`)**: Führt das Upgrade ohne interaktive Bestätigung durch – ideal für geplante Aufgaben und Skripte.
|
||||
- **Force-Option (`--force` / `-f`)**: Erlaubt das Erzwingen einer Neuinstallation / Aktualisierung auch bei gleicher Version oder erkannter Paketmanager-Umgebung.
|
||||
- **Paketmanager-Erkennung**: Erkennt automatisch, ob Sanctum über Scoop oder Winget ausgeführt wird, und empfiehlt den entsprechenden Paketmanager-Befehl (`scoop update sanctum` bzw. `winget upgrade HaraldPansi.Sanctum`).
|
||||
- **Testsuite**:
|
||||
- `tests/upgrade_test.rs`: 5 neue Tests zur Verifikation von Checksum-Parsing, SemVer-Vergleich, Paketmanager-Erkennung, SHA-256 Hash-Validierung und Gitea Release API-JSON-Deserialisierung.
|
||||
|
||||
## [0.5.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
- **`sanctum sync` - Native rsync-ähnliche Synchronisation**:
|
||||
- Bidirektionale Synchronisation (`Push`: Host -> Tresor, `Pull`: Tresor -> Host) direkt auf SQLite- und VFS-Ebene unter vollständiger Umgehung des WebDAV-Layers.
|
||||
- **Beseitigung des 4-GB-Dateilimits**: Löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher systembedingt auf 32-Bit Dateigrößen (~4 GB - 1 Byte / Fehler `0x800700DF`) beschränkt ist. Sanctum streamt nun Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+) in 1 MB Chunks mit AES-256-GCM direkt in/aus den Tresor.
|
||||
- **Dry-Run-Simulation (`--dry-run` / `-n`)**: Ermöglicht die risikolose Vorschau aller auszuführenden Aktionen (Hinzufügen, Aktualisieren, Löschen, Überspringen) samt Datenmengen-Berechnung, ohne tatsächliche Änderungen am Ziel durchzuführen.
|
||||
- **Spiegelung & Verwaiste Dateien bereinigen (`--delete`)**: Unterstützt das automatische Löschen von Dateien im Ziel, die in der Quelle nicht mehr existieren, für exakte Ordnerspiegelungen.
|
||||
- **Inhaltsbasierte Hash-Prüfung (`--checksum` / `-c`)**: Optionaler byteweiser SHA-256 Inhaltsvergleich anstelle des schnellen Größen-/mtime-Prüfverfahrens.
|
||||
- **Ausschlussmuster (`--exclude <PATTERN>`)**: Unterstützung für flexible Glob-Muster zum Ignorieren temporärer Dateien, Downloads oder System-Metadaten (z. B. `*.crdownload`, `*.tmp`, `Thumbs.db`).
|
||||
- **Quiet-Modus (`--quiet` / `-q`)**: Ermöglicht lautlose Ausführung für Skripte und geplante Aufgaben mit knapper Zusammenfassungsstatistik.
|
||||
- **Metadaten-Konservierung**: Erhaltung und Übertragung präziser Datei-Modifikationszeitstempel (`mtime`) in beide Richtungen.
|
||||
- **Integrationstests**:
|
||||
- `tests/sync_test.rs`: Umfassende automatisierte Testabdeckung für Push/Pull, Multi-Chunk Großdateien, Fast-Delta-Check, Dry-Run-Invarianz, `--delete` und `--exclude`.
|
||||
|
||||
## [0.4.1] - 2026-09-14
|
||||
|
||||
### Fixed
|
||||
- **Linux Mount-Stabilität & Signal-Handling**:
|
||||
- Behebung eines sofortigen automatischen Aushängens direkt nach erfolgreichem Mount unter Linux: Windows-spezifische Kontrollkanäle (Console-Close, Session-Lock, System-Tray) wurden auf Nicht-Windows-Systemen sofort geschlossen, wodurch `tokio::select!` über `None` vorzeitig ausgelöst wurde.
|
||||
- Plattform-spezifische Aufteilung der `tokio::select!`-Ereignisschleife: Unter Linux/Unix wird nun sauber auf POSIX-Terminierungssignale (`SIGINT`, `SIGTERM`, `SIGHUP`) gelauscht.
|
||||
- Defensives Pattern-Matching mit `Some(()) = rx.recv()` stellt sicher, dass geschlossene Kanäle keine unberechtigten Shutdowns mehr auslösen können.
|
||||
- Automatisches Öffnen via `xdg-open` auf Linux vorerst unterdrückt.
|
||||
|
||||
## [0.4.0] - 2026-09-10
|
||||
|
||||
### Added
|
||||
- **Distribution & Package Ecosystem**:
|
||||
- Offizielles Scoop-Manifest (`packages/scoop/sanctum.json`) für One-Click CLI-Installation und nahtlose Upgrades unter Windows.
|
||||
- Windows Package Manager (Winget) Manifest (`packages/winget/HaraldPansi.Sanctum.yaml`) zur systemweiten Paketverwaltung.
|
||||
- Vollständiger Installations-Leitfaden (`INSTALL.md`) für Scoop, Winget, Standalone-Binaries und `cargo install`.
|
||||
- **Cross-Platform Architektur (Linux & macOS Vorbereitung)**:
|
||||
- Neues Plattform-Abstraktionsmodul `src/platform/mod.rs` für OS-unabhängiges Speicherschutz- und Mount-Handling.
|
||||
- POSIX-Memory-Hardening mit `mlock` und `munlock` Systemaufrufen für Unix-Systeme.
|
||||
- Plattformübergreifendes Datei- und Ordneröffnen (`explorer.exe` unter Windows, `xdg-open` unter Linux, `open` unter macOS).
|
||||
- Native WebDAV-VFS-Einbindung für Linux GNOME/KDE via `gio mount`.
|
||||
- **Chaos Engineering & Live-Crash-Resilienz**:
|
||||
- `tests/live_crash_resilience_test.rs`: Umfassende automatisierte Stresstests mit Multi-Worker Schreibabbrüchen, asynchronen Panic-Simulationen und Validierung der SQLite WAL-Konsistenz (Null Korruption).
|
||||
- **Legal, Compliance & Software Bill of Materials (SBOM)**:
|
||||
- `LEGAL.md`: Rechtliche Leitlinien, US EAR § 742.15(b) & EU Dual-Use Exportkontroll-Klassifizierung, DSGVO Art. 25 Zero-Data-Erklärung und Haftungsausschluss nach § 521 BGB.
|
||||
- `THIRD_PARTY_LICENSES.md`: Lückenloses Lizenzaudit aller 231 Abhängigkeiten (100% permissive Lizenzen, 0% Copyleft) zur strikten Compliance mit Apache-2.0 Section 4.
|
||||
- Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das offizielle Release-Distributionsarchiv (`package-release.ps1`).
|
||||
- **Statisches Linux Single-Binary (`x86_64-unknown-linux-musl`)**:
|
||||
- 100% statisch gelinktes Linux-Binary ohne GLIBC-Abhängigkeiten via Zig-Cross-Toolchain.
|
||||
- Neues Linux-Distributionsarchiv `sanctum-v0.4.0-linux-x86_64.tar.gz` inklusive Dokumentation und Prüfsummen.
|
||||
- Native Freedesktop `.desktop`-Datei und MIME-Type-Integration (`application/x-sanctum`) via `sanctum register`.
|
||||
- **OpSec & Disaster Recovery UX**:
|
||||
- `QUICKSTART.md`: Druckbare 24-Wörter BIP-39 Notfallkarte zur sicheren analogen Verwahrung des Notfallschlüssels (Air-Gapped Vault Paper Backup).
|
||||
|
||||
## [0.3.1] - 2026-09-10
|
||||
|
||||
### Added
|
||||
- **Human Factors & OpSec-UX (HF-01 bis HF-04)**:
|
||||
- Maskierte interaktive Notfallschlüssel-Eingabe: `--recovery-key` ohne CLI-Argument liest die 24 BIP-39-Wörter via `rpassword` ein, ohne Klartextspuren in der PowerShell-Historie (`ConsoleHost_history.txt`) zu hinterlassen.
|
||||
- BIP-39 Resilienz & Tippfehler-Diagnose: Automatische String-Normalisierung (Kleinschreibung, Whitespace- und Satzzeichenbereinigung) sowie Levenshtein-Kandidatenvorschläge ($\le 2$ Distanz) bei Eingabefehlern.
|
||||
- Stealth-Mount Modus (`--stealth` / `-s`): Vollständig lautloser Betrieb ohne Banner, Token-Ausgabe, Mount-Pfade oder Box-Rahmen.
|
||||
- VFS Carrier-Schutz im Decoy-Vault: Umfassender Lösch-, Schreib-, Truncate- und Umbenennungsschutz der Alibi-Trägerdatei (`FsError::Forbidden`) bei Zugriff über Slot 0.
|
||||
- **Chaos Engineering & Crash-Resilienz (CHAOS-01 bis CHAOS-03)**:
|
||||
- Transaktionale Crash-Consistency (CHAOS-01): Bündelung von Chunk-Write und Dateigrößen-Aktualisierung (`write_chunk_and_update_size`) in einer einzigen atomaren SQLite-Transaktion (`tx.commit()`) für vollständige Resilienz gegen plötzlichen Stromausfall.
|
||||
- Path Fuzzing & Injection Guard (CHAOS-02): Strikte Validierung aller VFS-Pfade und Knotennamen gegen Null-Bytes (`\0`) und ASCII-Steuerzeichen ($< 0x20$).
|
||||
- Fault Recovery bei Disk Full (CHAOS-03): Sofortige Invalidierung des In-Memory-Chunk-Caches bei Schreibfehlern zur Vermeidung kaskadierender Drop-Panics bei vollem Zieldatenträger.
|
||||
- **DFIR & Windows Host-Memory Hardening**:
|
||||
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
|
||||
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
|
||||
|
||||
### Changed
|
||||
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
|
||||
- Beseitigung verräterischer Konsolenausgaben (magenta hervorgehobene Hinweise auf Hidden Vault / Slot 1) und Harmonisierung des Explorer-Verhaltens für beide Safes.
|
||||
- Vereinheitlichung der Ausgaben von `sanctum verify` und `sanctum recovery-key` zur Vermeidung von Rückschlüssen auf vorhandene Hidden-Vault-Slots.
|
||||
- Interaktiver Assistent für `sanctum restore-header` bei Aufruf ohne CLI-Parameter.
|
||||
|
||||
## [0.3.0] - 2026-09-09
|
||||
|
||||
### Added
|
||||
- **Modell A: Steganografischer Carrier & Plausible Deniability Phase 2**:
|
||||
- Zwei-Schichten-AEAD (`DEK_0` + `DEK_1`) zur vollständigen Abwehr von Chunks-Accounting-Angriffen.
|
||||
- Physische Dateigrößen-Invarianz: Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
|
||||
- Virtuelles Carrier-Dateisystem (`CarrierFs`) mit reserviertem Block 0 für Manifest und Block-Allokationsbitmap.
|
||||
- Schreib-, Lösch- und Umbenennungsschutz der Alibi-Trägerdatei im Decoy-Mount (`FsError::Forbidden`).
|
||||
- **CLI & Disaster Recovery Erweiterungen**:
|
||||
- `sanctum backup` & `sanctum restore`: Dedizierte CLI-Befehle für konsistente Online-Sicherungen.
|
||||
- `sanctum restore-header --slot <0|1>`: Gezielte Wiederherstellung für Decoy- oder Hidden-Vault-Header.
|
||||
- `sanctum init --carrier-file <NAME> --carrier-size <MB>`: Frei konfigurierbare Alibi-Trägerdatei mit forensischen Sicherheitsprüfungen.
|
||||
- **SRE & Production Resilience**:
|
||||
- Nativer Win32 `SetConsoleCtrlHandler`: Synchrones Aushängen und WAL-Checkpointing bei Schließen des Konsolenfensters (`CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, `CTRL_SHUTDOWN_EVENT`).
|
||||
- Stale Mount Self-Healing: Automatische Erkennung und Bereinigung verwaister Windows-Netzlaufwerke (Systemfehler 85).
|
||||
- Automatische Erkennung und Diagnosehilfe für den Windows `WebClient`-Dienst (Fehler 67).
|
||||
- **Red Team & Threat Model Hardening**:
|
||||
- WebDAV Loopback Protection: Dynamisches 128-Bit Session-Token im URL-Pfad (Schutz vor unprivilegierten lokalen Prozessen & CSRF).
|
||||
- Anti-DNS-Rebinding & Anti-Spoofing: Strikte Fail-Closed Host-Header-Validierung.
|
||||
- Anti-Slowloris & Connection Limiting: Begrenzung auf maximal 64 gleichzeitige WebDAV-Verbindungen und 15s Header-Read-Timeout.
|
||||
- Decompression-Bomb-Schutz: Strikter 1-MB-Größen-Guard vor LZ4-Dekomprimierung zur Vermeidung von Speichererschöpfung (CWE-400).
|
||||
- Verzeichnis-Hijacking-Schutz: Dynamische Bindung verschlüsselter Knotennamen an die `parent_id` via AEAD-AAD.
|
||||
- Vollständige RAM-Zeroization: Schutz sensibler Daten im Heap via `zeroize::Zeroize` bei Puffer-Swaps und `Drop`.
|
||||
|
||||
### Changed
|
||||
- Standardname der Trägerdatei auf forensisch plausiblen Typ `system_backup.dat` geändert.
|
||||
- Heap- und Schreiboptimierung: Reduzierung der CSPRNG-Padding-Generierung auf den tatsächlichen Slack-Bereich (~30-fache Schreibbeschleunigung).
|
||||
- Inaktivitäts-Tracking: `self.touch()` reagiert nur noch auf echte I/O-Interaktionen (`open`, read, write) und ignoriert passive Explorer-Hintergrundabfragen (`metadata`, `read_dir`).
|
||||
- LZ4-Kompression: Schwellenwert von mindestens 64 Bytes Ersparnis eingeführt (`compressed.len() + 64 <= plaintext.len()`).
|
||||
|
||||
## [0.2.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
- **Transparente LZ4-Kompression**:
|
||||
- `lz4_flex` Integration mit Frame-Format vor der AEAD-Verschlüsselung.
|
||||
- Adaptiver Fallback: Chunks werden nur komprimiert, wenn mindestens 64 Bytes gespart werden.
|
||||
- Transparente On-the-Fly Dekompression bei Leseoperationen.
|
||||
- **Disaster Recovery & Integrität**:
|
||||
- BIP-39 Mnemonic Seed Phrases (24 Wörter) für Master-Key Recovery.
|
||||
- `sanctum recovery-key`: Export und Anzeige des BIP-39 Notfallschlüssels.
|
||||
- `sanctum backup` & `sanctum restore`: Konsistente Online-Sicherungen via SQLite Backup API mit WAL-Flush.
|
||||
- `sanctum verify`: Vollständige kryptografische Integritätsprüfung aller AEAD-Chunks und B-Tree-Validierung.
|
||||
- **Windows Explorer Integration & Bedienkomfort**:
|
||||
- `sanctum register` & `sanctum unregister`: Windows-Kontextmenü im Explorer (`HKCU\Software\Classes\.sanctum`, "In Sanctum öffnen", Icon-Zuordnung).
|
||||
- Intelligente Erkennung freier Laufwerksbuchstaben (von Z: abwärts).
|
||||
- Automatisches Öffnen des gemounteten Laufwerks im Windows Explorer.
|
||||
- System-Tray Icon (`tray-icon`) im Windows Infobereich mit Status und schnellem Unmount.
|
||||
- **Explorer Anti-Leak Shield & OpSec**:
|
||||
- Windows Anti-Leak Filter: Blockiert `thumbs.db`, `desktop.ini`, `*.tmp`, Office-Lockfiles (`~$*`) und NTFS Alternative Data Streams (`:Zone.Identifier`).
|
||||
- Activity Tracking & Inaktivitäts-Timeout: Automatischer Unmount bei Leerlauf.
|
||||
- Windows Session Lock Monitor: Registrierung für `WTS_SESSION_LOCK` mit sicherem Auto-Unmount beim Sperren des PCs.
|
||||
- **Storage Compaction & Anti-Forensics**:
|
||||
- SQLite Incremental Auto-Vacuum (`PRAGMA auto_vacuum = INCREMENTAL;`) zur physikalischen Freigabe ungenutzter Dateiseiten an das Host-Dateisystem.
|
||||
- Kryptografisches Chunk-Shredding: Chunks werden vor dem Löschen/Abschneiden mit CSPRNG-Zufallsrauschen überschrieben.
|
||||
- `sanctum compact`: Manuelles oder beim Aushängen automatisches Freigeben von Freelist-Pages.
|
||||
- Plausible Deniability (Hidden Vault): Dual-Slot Header-Architektur. Unbenutzte Slots enthalten ununterscheidbares CSPRNG-Rauschen.
|
||||
- `sanctum init --with-hidden`: Erstellung zweier getrennter Safes (Decoy & Hidden) mit individuellen Passphrasen und BIP-39 Recovery-Karten.
|
||||
- Vollständige Dateinamen-Verschlüsselung für den Hidden Vault.
|
||||
- **Passwort-Verwaltung (`sanctum passwd`)**:
|
||||
- Sofortiges Ändern des Master-Passworts über DEK-Rewrapping mit neuem Argon2id-Salt und KEK-Parametern ohne Neuverschlüsselung der Nutzdaten. Multi-Slot fähig.
|
||||
|
||||
## [0.1.0] - 2026-09-07
|
||||
|
||||
|
||||
Generated
+503
-12
@@ -104,7 +104,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -115,7 +115,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -136,6 +136,12 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -160,6 +166,33 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"serde",
|
||||
"unicode-normalization",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.14.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
|
||||
dependencies = [
|
||||
"hex-conservative",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
@@ -175,6 +208,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -273,18 +312,82 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"block",
|
||||
"cocoa-foundation",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa-foundation"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"block",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -394,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -409,6 +512,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -421,6 +530,33 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-macros"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -596,6 +732,21 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "htmlescape"
|
||||
version = "0.3.1"
|
||||
@@ -875,6 +1026,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -896,6 +1053,24 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -927,6 +1102,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -935,7 +1116,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -944,7 +1125,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -956,6 +1137,35 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_id"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||
dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -974,6 +1184,12 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "padlock"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c10569378a1dacd9f30dbe7ae49e054d2c45dc2f8ee49899903e09c3924e8b6f"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1116,7 +1332,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1136,6 +1352,20 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
@@ -1144,7 +1374,7 @@ checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1154,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1163,7 +1393,7 @@ version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -1171,6 +1401,54 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1179,28 +1457,40 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.1.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"bip39",
|
||||
"bytes",
|
||||
"clap",
|
||||
"dav-server",
|
||||
"dyn-clone",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"lz4_flex",
|
||||
"minisign-verify",
|
||||
"rand",
|
||||
"rpassword",
|
||||
"rusqlite",
|
||||
"self-replace",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tray-item",
|
||||
"ureq",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1210,6 +1500,23 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "self-replace"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"tempfile",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1264,6 +1571,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1308,7 +1626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1362,6 +1680,19 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
@@ -1401,6 +1732,21 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -1415,7 +1761,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1504,6 +1850,28 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-item"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59d4bd406170690dc30eabb3badc67a085beaf9b2c3b1923afcc9c26a2191353"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"core-graphics",
|
||||
"libc",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"padlock",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -1522,6 +1890,15 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -1532,6 +1909,29 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1636,6 +2036,24 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -1695,6 +2113,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1704,6 +2131,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
||||
+32
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.1.0"
|
||||
version = "0.9.1"
|
||||
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,13 +12,14 @@ 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"] }
|
||||
aes-gcm = { version = "0.10", features = ["zeroize"] }
|
||||
rand = "0.8"
|
||||
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled", "backup"] }
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
dav-server = { version = "0.11", default-features = false }
|
||||
hyper = { version = "1.4", features = ["server", "http1"] }
|
||||
@@ -33,10 +34,38 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dyn-clone = "1.0"
|
||||
lz4_flex = "0.11"
|
||||
bip39 = { version = "2.2", features = ["zeroize"] }
|
||||
hex = "0.4"
|
||||
self-replace = "1.5"
|
||||
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]
|
||||
tray-item = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
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"
|
||||
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Installation & Paketmanager-Guide für Sanctum 🛡️
|
||||
|
||||
Sanctum kann auf verschiedene Arten unter Windows 10/11 installiert und verwaltet werden. Es sind **keine Administratorrechte** erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. Installation via Scoop (Empfohlen für Entwickler)
|
||||
|
||||
Wenn du den Windows-Paketmanager [Scoop](https://scoop.sh/) nutzt, kannst du Sanctum direkt über das bereitgestellte Manifest installieren:
|
||||
|
||||
```powershell
|
||||
# Direkt über die URL des Manifests installieren:
|
||||
scoop install https://gitea.pansi.eu/harald/sanctum/raw/branch/main/packages/scoop/sanctum.json
|
||||
```
|
||||
|
||||
Oder falls du ein lokales Repository geklont hast:
|
||||
|
||||
```powershell
|
||||
scoop install .\packages\scoop\sanctum.json
|
||||
```
|
||||
|
||||
Nach der Installation ist der Befehl `sanctum` sofort global in deiner PowerShell verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## 🪟 2. Installation via Windows Package Manager (Winget)
|
||||
|
||||
Sanctum verfügt über ein standardkonformes Winget-Manifest (Schema 1.6.0).
|
||||
|
||||
### Lokale Installation via Manifest:
|
||||
```powershell
|
||||
winget install --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
|
||||
```
|
||||
|
||||
Winget entpackt das portable Binary automatisch in das Benutzer-Anwendungsverzeichnis und verknüpft das Alias `sanctum`.
|
||||
|
||||
---
|
||||
|
||||
## 📦 3. Manuelle Installation (Standalone Portable)
|
||||
|
||||
1. Lade das aktuelle Release-Paket herunter:
|
||||
* **URL:** [https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0](https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0)
|
||||
* **Datei:** `sanctum-v0.4.0-windows-x86_64.zip`
|
||||
2. Entpacke das Archiv in ein Verzeichnis deiner Wahl (z. B. `C:\Tools\Sanctum\`).
|
||||
3. *(Optional)* Registriere das Windows Explorer Rechtsklick-Kontextmenü:
|
||||
```powershell
|
||||
.\sanctum.exe register
|
||||
```
|
||||
*(Erlaubt das Einhängen per Rechtsklick "In Sanctum öffnen" ohne Adminrechte).*
|
||||
|
||||
---
|
||||
|
||||
## 🐧 4. Installation unter Linux (x86_64)
|
||||
|
||||
Sanctum wird für Linux als **100 % statisches Single-Binary** (`x86_64-unknown-linux-musl`) ohne jegliche GLIBC- oder Bibliotheksabhängigkeiten bereitgestellt. Es läuft direkt auf Ubuntu, Debian, Alpine, Arch, Fedora und weiteren Distributionen:
|
||||
|
||||
1. Lade das Linux-Release herunter und installiere es:
|
||||
```bash
|
||||
curl -LO https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-linux-x86_64.tar.gz
|
||||
tar -xzf sanctum-v0.4.0-linux-x86_64.tar.gz
|
||||
chmod +x sanctum
|
||||
sudo install -m 755 sanctum /usr/local/bin/sanctum
|
||||
```
|
||||
2. *(Optional)* Freedesktop-Integration einrichten (Doppelklick in GNOME / KDE / Thunar):
|
||||
```bash
|
||||
sanctum register
|
||||
```
|
||||
*Richtet die `.desktop`-Datei und den MIME-Typ `application/x-sanctum` im Userland (`~/.local/share`) ein.*
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates
|
||||
|
||||
* **Scoop:**
|
||||
```powershell
|
||||
scoop update sanctum
|
||||
```
|
||||
* **Winget:**
|
||||
```powershell
|
||||
winget upgrade --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗑️ Deinstallation
|
||||
|
||||
* **Scoop:**
|
||||
```powershell
|
||||
scoop uninstall sanctum
|
||||
```
|
||||
* **Explorer-Kontextmenü rückstandslos bereinigen:**
|
||||
```powershell
|
||||
sanctum.exe unregister
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Rechtliche Hinweise, Compliance & Exportkontrolle (LEGAL)
|
||||
|
||||
Dieses Dokument regelt die rechtlichen Rahmenbedingungen, Exportkontroll-Klassifizierungen, Lizenz-Compliance sowie die datenschutzrechtlichen und haftungsrechtlichen Grundlagen der Software **Sanctum**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kryptografie-Exportkontrolle & Dual-Use-Klassifizierung
|
||||
|
||||
Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrischen Schlüssellängen von 256 Bit (AES-256-GCM), asymmetrischer KDF (Argon2id) sowie BIP-39 Schlüsselableitung.
|
||||
|
||||
### A. US Export Administration Regulations (EAR)
|
||||
* **Klassifizierung (ECCN)**:
|
||||
* Hardware/Software mit starker Verschlüsselung fällt unter **ECCN 5A002.a** bzw. **5D002** (Commerce Control List, Category 5, Part 2: *Information Security*).
|
||||
* **Ausnahmetatbestand für frei verfügbaren Quellcode**:
|
||||
* Gemäß **15 C.F.R. § 742.15(b)** (*Publicly Available Encryption Source Code*) und **15 C.F.R. § 734.3(b)(3)** unterliegt öffentlich zugänglicher Open-Source-Quellcode, der jedermann unentgeltlich und ohne Zugriffsbeschränkungen zur Verfügung steht, **nicht** den Ausfuhrgenehmigungspflichten der EAR.
|
||||
* Sanctum wird unter der freien MIT-Lizenz auf frei zugänglichen Repositorien (einschließlich Quellcode, Spezifikationen und Build-Skripten) bereitgestellt.
|
||||
* **Sanktionierte Staaten & Personen (OFAC)**:
|
||||
* Trotz der allgemeinen Open-Source-Freistellung ist der wissentliche direkte oder indirekte Export/Re-Export an Personen oder Entitäten auf US-Sanktionslisten (OFAC Specially Designated Nationals List - SDN) sowie in Länder unter umfassenden Handelsembargos (Kuba, Iran, Nordkorea, Syrien, besetzte Gebiete der Ukraine) untersagt.
|
||||
|
||||
### B. Europäische Union (EU Dual-Use-Verordnung 2021/821)
|
||||
* **Anhang I, Kategorie 5 Teil 2**:
|
||||
* Systeme der Informationssicherheit sind grundsätzlich im Anhang I der Dual-Use-Verordnung gelistet.
|
||||
* **Allgemeine Software-Anmerkung (General Software Note - GSN)**:
|
||||
* Gemäß der GSN zu Anhang I der Verordnung (EU) 2021/821 unterliegt Software **nicht** der Ausfuhrkontrolle, wenn sie:
|
||||
1. Allgemein zugänglich ist (*in the public domain*), z. B. durch uneingeschränkten, unentgeltlichen Download; oder
|
||||
2. Der Mindest-Quellcode für die Installation, den Betrieb oder die Wartung nicht-kontrollierter Güter ist.
|
||||
* Da Sanctum als freie Open-Source-Software jedermann unentgeltlich zur Verfügung steht, greift die Freistellung der GSN.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dual-Vault-Architektur (Second Safe) & Herausgabepflichten (Key Disclosure Laws)
|
||||
|
||||
Sanctum implementiert mit **Modell A** eine Dual-Vault-Architektur: Im äußeren Standard-Tresor (Slot 0 / Decoy) wird eine scheinbare Alibi-Datei (Carrier) platziert, die als Träger für den zweiten Tresor (Slot 1 / Second Safe) dient.
|
||||
|
||||
> [!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 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.
|
||||
> 2. **Internationale Rechtsordnungen (z. B. UK, USA)**:
|
||||
> * In Rechtsordnungen mit strafbewehrten Herausgabeanordnungen (z. B. UK RIPA Section 49) schützt Sanctum nicht vor Verurteilungen wegen Missachtung gerichtlicher Anordnungen, falls Ermittler die Existenz verschlüsselter Trägerblöcke feststellen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Haftungsausschluss & Gewährleistung (EU / DACH Recht)
|
||||
|
||||
Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT WARRANTY OF ANY KIND“*) wird für den Geltungsbereich des europäischen und deutschen Rechts wie folgt präzisiert und ergänzt:
|
||||
|
||||
### A. Schenkungsrechtliche Haftungsbeschränkung (§ 521 BGB)
|
||||
1. Sanctum wird dem Anwender unentgeltlich und ohne Gegenleistung überlassen.
|
||||
2. Gemäß **§ 521 des Bürgerlichen Gesetzbuches (BGB)** haftet der Urheber / Entwickler bei unentgeltlicher Softwareüberlassung **nur für Vorsatz und grobe Fahrlässigkeit**.
|
||||
3. Die Haftung für einfache/leichte Fahrlässigkeit, mittelbare Schäden, Folgeschäden, Datenverluste, entgangenen Gewinn oder Betriebsunterbrechungen ist im gesetzlich zulässigen Rahmen vollständig ausgeschlossen.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenschutz & DSGVO / GDPR (Zero-Telemetry-Garantie)
|
||||
|
||||
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. 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.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Sanctum 🛡️ — Schnellstartanleitung & Notfallhandbuch
|
||||
|
||||
Willkommen bei **Sanctum**! Diese Anleitung führt dich in wenigen Minuten durch die grundlegenden Funktionen zur sicheren Verwaltung deiner verschlüsselten Container unter Windows 10/11 – **100% Userland, ohne Administratorrechte und ohne Treiber**.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ In 3 Minuten startklar
|
||||
|
||||
```powershell
|
||||
# 1. Neuen Tresor mit Notfallkarte anlegen:
|
||||
sanctum.exe init --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
|
||||
|
||||
# 2. Tresor einbinden (wählt automatisch den nächsten freien Buchstaben, z. B. Z:):
|
||||
sanctum.exe mount --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
|
||||
|
||||
# 3. Sicher trennen:
|
||||
# Entweder Ctrl+C im Konsolenfenster ODER Rechtsklick auf das Schild-Icon im Infobereich (Systray) -> "Aushängen & Beenden".
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 Die Kernfunktionen im Überblick
|
||||
|
||||
### 1. Tresor initialisieren (`init`)
|
||||
|
||||
Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
||||
|
||||
* **Standard-Tresor (Single-Vault)**:
|
||||
```powershell
|
||||
sanctum.exe init --path "D:\Tresor\daten.sanctum"
|
||||
```
|
||||
* **Dual-Vault Tresor (Zweittresor mit Alibi-Carrier)**:
|
||||
```powershell
|
||||
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
|
||||
```
|
||||
* Hier legst du **zwei verschiedene Passwörter** fest (mindestens 12 Zeichen):
|
||||
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.500–7.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).
|
||||
* **Migration von V1-Containern**: Bestehende V1-Hidden-Vaults werden transparent unterstützt und können auf Format V2 migriert werden. Siehe [Schritt-für-Schritt-Anleitung in der README](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
|
||||
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
||||
|
||||
---
|
||||
|
||||
### 2. Tresor einbinden (`mount`)
|
||||
|
||||
Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- oder Hidden-Safe geöffnet werden soll.
|
||||
|
||||
| Befehl | Zweck |
|
||||
| :--- | :--- |
|
||||
| `sanctum mount --path <DATEI>` | Standard-Mount. Öffnet das Laufwerk automatisch im Explorer. |
|
||||
| `sanctum mount --path <DATEI> --drive S` | Bindet den Tresor fest an den Buchstaben `S:` (statt automatischer Wahl). |
|
||||
| `sanctum mount --path <DATEI> --idle-timeout 300` | Trennt das Laufwerk automatisch nach 5 Minuten (300 Sek.) Inaktivität. |
|
||||
| `sanctum mount --path <DATEI> --no-open` | Verhindert das automatische Öffnen des Windows Explorers (Schutz vor ShellBag-Spuren). |
|
||||
| `sanctum mount --path <DATEI> --stealth` | **Lautloser Stealth-Modus**: Keine Terminal-Ausgaben, keine URLs, kein Explorer-Start. |
|
||||
|
||||
---
|
||||
|
||||
### 3. Notfallrettung & Wartung
|
||||
|
||||
* **Integritätsprüfung (FSCK)**:
|
||||
```powershell
|
||||
sanctum.exe verify --path "D:\Tresor\daten.sanctum"
|
||||
```
|
||||
Überprüft die B-Tree-Struktur der Datenbank und testet sämtliche Chunks gegen ihre kryptografischen AEAD-Authentifizierungs-Tags.
|
||||
|
||||
* **Online-Backup im laufenden Betrieb**:
|
||||
```powershell
|
||||
sanctum.exe backup --path "D:\Tresor\daten.sanctum" --output "E:\Backup\daten_backup.sanctum"
|
||||
```
|
||||
Erzeugt über die SQLite Online Backup API eine konsistente Kopie – selbst während Dateien geöffnet sind.
|
||||
|
||||
* **Passwort vergessen? Wiederherstellung via BIP-39 Notfallschlüssel**:
|
||||
```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. 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).*
|
||||
|
||||
---
|
||||
|
||||
## 🖨️ Druckvorlage: BIP-39 Notfallkarte
|
||||
|
||||
Drucke diesen Abschnitt aus oder übertrage die Wörter handschriftlich auf ein Blatt Papier. Bewahre diese Karte physisch getrennt von deinem Computer an einem sicheren Ort (z. B. Tresor, Dokumentenmappe) auf.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SANCTUM — KRYPTOGRAFISCHE NOTFALL-WIEDERHERSTELLUNGSKARTE (BIP-39) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Container: __________________________________________________________________
|
||||
Erstelldatum: ____.___.202__ Safe: [ ] Standard [ ] Hidden Vault
|
||||
Hinweis: Alle Wörter sind in Kleinbuchstaben aus der offiziellen BIP-39 Liste.
|
||||
|
||||
┌────┬────────────────────────────┬────┬────────────────────────────┐
|
||||
│ # │ WORT │ # │ WORT │
|
||||
├────┼────────────────────────────┼────┼────────────────────────────┤
|
||||
│ 01 │ __________________________ │ 13 │ __________________________ │
|
||||
│ 02 │ __________________________ │ 14 │ __________________________ │
|
||||
│ 03 │ __________________________ │ 15 │ __________________________ │
|
||||
│ 04 │ __________________________ │ 16 │ __________________________ │
|
||||
│ 05 │ __________________________ │ 17 │ __________________________ │
|
||||
│ 06 │ __________________________ │ 18 │ __________________________ │
|
||||
│ 07 │ __________________________ │ 19 │ __________________________ │
|
||||
│ 08 │ __________________________ │ 20 │ __________________________ │
|
||||
│ 09 │ __________________________ │ 21 │ __________________________ │
|
||||
│ 10 │ __________________________ │ 22 │ __________________________ │
|
||||
│ 11 │ __________________________ │ 23 │ __________________________ │
|
||||
│ 12 │ __________________________ │ 24 │ __________________________ │
|
||||
└────┴────────────────────────────┴────┴────────────────────────────┘
|
||||
|
||||
⚠️ SICHERHEITSHINWEISE:
|
||||
1. Wer im Besitz dieser 24 Wörter ist, kann den Tresor ohne Passwort entschlüsseln!
|
||||
2. Niemals abfotografieren, in Cloud-Notizen speichern oder unverschlüsselt versenden.
|
||||
3. Bei Verlust beider Passwörter und dieser Karte sind die Daten unwiederbringlich verloren.
|
||||
```
|
||||
@@ -1,42 +1,68 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.png" alt="Sanctum Logo" width="220"/>
|
||||
</p>
|
||||
|
||||
# Sanctum 🛡️
|
||||
|
||||
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die einen verschlüsselten Ein-Datei-Container (`.sanctum`) unter Windows (10/11) im reinen Userland verwaltet.
|
||||
**Verschlüsselter Ein-Datei-Container unter Windows & Linux im reinen Userland**
|
||||
|
||||
- **Keine Administratorrechte erforderlich**
|
||||
- **Keine Kernel-Treiber** (weder WinFsp noch Dokan)
|
||||
- **Transparenter Windows Explorer-Zugriff** über einen integrierten lokalen WebDAV-Server (`127.0.0.1`) via Windows-Bordmittel (`net use`)
|
||||
- **Statisches Single-Binary** (`sanctum.exe`, ~4.6 MB) ohne externe DLL-Abhängigkeiten
|
||||
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 und Linux im reinen Userland verwaltet.
|
||||
|
||||
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
|
||||
- **Windows Explorer- & Desktop-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon unter Windows sowie `gio mount` / `.desktop`-MIME unter Linux.
|
||||
- **Native Synchronisation (`sanctum sync`)**: RSync-artiger bidirektionaler Dateiabgleich (`Push` & `Pull`) mit 1-MB-Chunk-Streaming direkt in SQLite. Umgeht das fundamentale 4-GB-Dateilimit des Windows WebClient-Dienstes (`0x800700DF`) für Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+). Inklusive `--dry-run` Simulation, `--delete` Spiegelung und `--checksum` Hashprüfung.
|
||||
- **In-Place Self-Upgrade (`sanctum upgrade`)**: Aktualisiert das laufende Binary per Knopfdruck direkt über Gitea Releases. Löst Windows File-Locking via `self-replace`, verifiziert jedes Release kryptografisch via **Minisign** (`SHA256SUMS.txt.minisig`) und sperrt Drittanbieter-URLs standardmäßig ab.
|
||||
- **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback.
|
||||
- **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
|
||||
- **Dual-Vault / Second Safe**: 2-Slot-Architektur mit Alibi-Trägerdatei (Modell A). Erlaubt die Trennung von Standard- und vertraulichen Zweittresor-Daten (z. B. Schutz vor Schulterblick im Alltag). Volle Dateinamen-Verschlüsselung im Second Safe.
|
||||
- **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`).
|
||||
- **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`).
|
||||
- **Statisches Single-Binary**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
|
||||
|
||||
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md).
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Kryptografie & Sicherheitsarchitektur
|
||||
|
||||
- **Key Derivation (Argon2id)**:
|
||||
- **Schlüsselableitung (Argon2id)**:
|
||||
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
||||
- **Data Encryption Key (DEK)**:
|
||||
Ein zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
||||
- **RAM-Schutz**:
|
||||
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im Arbeitsspeicher sicher zu nullen.
|
||||
Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
||||
- **Speichersicherheit (Zeroize & VirtualLock)**:
|
||||
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen. Schlüsseldaten werden mittels `VirtualLock` / `mlock` vor Paging geschützt.
|
||||
- **Chunk-Verschlüsselung (AES-256-GCM)**:
|
||||
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
||||
- **Schutz vor Swap-Angriffen**:
|
||||
Als Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) fest eingebunden. Dadurch wird verhindert, dass Chunks zwischen Dateien oder innerhalb einer Datei vertauscht werden können.
|
||||
- **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 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.500–7.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 & Schutz vor Swap-Attacks**:
|
||||
Dateinamen werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert, wobei die übergeordnete Verzeichnis-ID (`parent_id`) als Additional Authenticated Data (AAD) eingebunden ist. Dies verhindert, dass Angreifer verschlüsselte Dateinamen zwischen Verzeichnissen vertauschen können.
|
||||
> [!WARNING]
|
||||
> **Sicherheitshinweis zu `--legacy-names`**: Das globale Flag `--legacy-names` deaktiviert die Prüfung der AAD-Bindung bei der Entschlüsselung von Dateinamen. Verwenden Sie dieses Flag **ausschließlich zur einmaligen Datenrettung oder Migration** älterer Container (vor Version 0.8.0), da es das Schutzniveau gegen Manipulationen reduziert.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Storage Engine (SQLite3 im VFS-Container)
|
||||
## 📦 Speicher- & Kompressions-Engine
|
||||
|
||||
Der gesamte Container besteht aus exakt **einer** Datei auf der Host-Festplatte (`.sanctum`), die dynamisch bis über 100 GB wachsen kann.
|
||||
Der Container besteht aus exakt **einer** Host-Datei (`.sanctum`), die dynamisch bis über 100 GB wachsen kann:
|
||||
|
||||
- **Pragmas**:
|
||||
- **SQLite3 WAL & Auto-Vacuum**:
|
||||
- `PRAGMA auto_vacuum = INCREMENTAL;`
|
||||
- `PRAGMA journal_mode = WAL;`
|
||||
- `PRAGMA synchronous = NORMAL;`
|
||||
- `PRAGMA page_size = 8192;`
|
||||
- `PRAGMA foreign_keys = ON;`
|
||||
- **Tabellen**:
|
||||
- `meta`: Container-Header mit Magic Bytes (`SANCTUM\0`), Version 1, Salt, KDF-Parametern und Wrapped DEK.
|
||||
- `nodes`: Verzeichnis- und Dateiknoten mit Hierarchiebaum und Zeitstempeln.
|
||||
- `chunks`: Verschlüsselte Nutzdatenblöcke mit Nonce und Authentifizierungstag.
|
||||
- **LZ4-Kompression**:
|
||||
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)).
|
||||
|
||||
---
|
||||
|
||||
@@ -52,42 +78,282 @@ Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Verwendung
|
||||
## 🛠️ CLI-Referenz & Verwendung
|
||||
|
||||
### 1. Container initialisieren
|
||||
|
||||
### 1. Neuen Container anlegen
|
||||
```powershell
|
||||
sanctum.exe init --path "C:\Users\username\Documents\safe.sanctum"
|
||||
```
|
||||
# Standard-Container anlegen:
|
||||
sanctum.exe init --path "C:\Pfad\tresor.sanctum"
|
||||
|
||||
# Container mit Zweittresor (Dual-Vault: Decoy + Second Safe) anlegen:
|
||||
sanctum.exe init --path "C:\Pfad\tresor.sanctum" --with-hidden
|
||||
```
|
||||
*(Gibt nach Passworteingabe eine 24-Wort BIP-39 Notfall-Wiederherstellungskarte aus).*
|
||||
|
||||
---
|
||||
|
||||
### 2. Container einbinden (Mount)
|
||||
|
||||
### 2. Container als Windows-Laufwerk einbinden
|
||||
```powershell
|
||||
sanctum.exe mount --path "C:\Users\username\Documents\safe.sanctum" --drive S
|
||||
```
|
||||
Das Netzlaufwerk `S:` steht sofort im Windows Explorer zur Verfügung.
|
||||
Zum Beenden und sicheren Trennen einfach `Ctrl+C` im Terminal drücken.
|
||||
# Automatische Wahl des nächsten freien Laufwerksbuchstabens (z. B. Z:):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum"
|
||||
|
||||
# Bestimmten Laufwerksbuchstaben erzwingen:
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
|
||||
|
||||
# Mit Inaktivitäts-Timeout (in Sekunden):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
|
||||
|
||||
# Lautloser Stealth-Modus (keine Banner/Token-Ausgabe, kein Explorer-Auto-Open):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --stealth
|
||||
|
||||
# Explorer-Auto-Open gezielt unterdrücken (maximale ShellBag-OpSec):
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### 3. Container manuell trennen
|
||||
|
||||
### 3. Laufwerk manuell trennen
|
||||
```powershell
|
||||
sanctum.exe unmount --drive S
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Release Packaging
|
||||
### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`)
|
||||
|
||||
Um ein vollständiges Release-Paket mit Tests, komprimiertem ZIP-Archiv und SHA-256 Prüfsummen zu erstellen:
|
||||
```powershell
|
||||
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
|
||||
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
> [!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**.
|
||||
|
||||
---
|
||||
|
||||
### 5. Disaster Recovery & Notfallschlüssel
|
||||
|
||||
```powershell
|
||||
# 24-Wort BIP-39 Notfallschlüssel anzeigen:
|
||||
sanctum.exe recovery-key --path "C:\Pfad\tresor.sanctum"
|
||||
|
||||
# Konsistentes Online-Backup erstellen (auch während Mount möglich):
|
||||
sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "D:\Backup\tresor_backup.sanctum"
|
||||
|
||||
# Container aus Backup wiederherstellen:
|
||||
sanctum.exe restore --path "D:\Backup\tresor_backup.sanctum" --output "C:\Pfad\tresor_restored.sanctum"
|
||||
|
||||
# Vollständige Integritätsprüfung (B-Tree, Knoten und AEAD-Tags aller Chunks):
|
||||
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Storage Compaction (Speicherbereinigung)
|
||||
|
||||
```powershell
|
||||
# Freelist-Pages an Windows freigeben und Container verkleinern:
|
||||
sanctum.exe compact --path "C:\Pfad\tresor.sanctum"
|
||||
|
||||
# Nur eine begrenzte Anzahl Pages freigeben:
|
||||
sanctum.exe compact --path "C:\Pfad\tresor.sanctum" --pages 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Windows Explorer Kontextmenü-Integration
|
||||
|
||||
```powershell
|
||||
# .sanctum-Dateien für Rechtsklick ("In Sanctum öffnen") in HKCU registrieren (ohne Adminrechte):
|
||||
sanctum.exe register
|
||||
|
||||
# Registrierung wieder rückstandslos entfernen:
|
||||
sanctum.exe unregister
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Native Synchronisation (`sanctum sync`)
|
||||
|
||||
Sanctum bietet eine native, rsync-ähnliche Synchronisations-Engine, die Dateien direkt auf Datenbank- und Chunk-Ebene überträgt. Dies löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher beim Kopieren über gemountete WebDAV-Netzlaufwerke konstruktionsbedingt auf maximal 4 GB pro Datei beschränkt ist (`FileSizeLimitInBytes` / Fehler `0x800700DF`).
|
||||
|
||||
```powershell
|
||||
# 1. Vorab-Simulation ausführen (Dry-Run / risikolos prüfen):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --dry-run
|
||||
|
||||
# 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, 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
|
||||
|
||||
# 5. Daten aus dem Tresor wiederherstellen (Pull / Container -> Host):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" /Downloads "D:\Wiederhergestellt\Downloads" --pull
|
||||
```
|
||||
|
||||
- **Dateigrößen**: Dateien beliebiger Größe (**5 GB, 50 GB, 500 GB+**) werden in 1-MB-Streaming-Chunks mit AES-256-GCM direkt übertragen.
|
||||
- **Fast-Delta Check**: Dateien mit identischer Größe und Modifikationszeitstempel (`mtime`) werden sofort übersprungen.
|
||||
- **Metadaten-Erhaltung**: Datei-Zeitstempel (`mtime`) werden in beide Richtungen exakt konserviert.
|
||||
|
||||
---
|
||||
|
||||
### 9. In-Place Self-Upgrade (`sanctum upgrade`)
|
||||
|
||||
Aktualisiert die laufende Sanctum-Executable direkt über die offizielle Gitea-Releases-API auf die neueste Version, ohne manuelle Browser-Downloads oder separate Paketmanager:
|
||||
|
||||
```powershell
|
||||
# Nur prüfen, ob ein Update verfügbar ist:
|
||||
sanctum.exe upgrade --check
|
||||
# oder mit dem Alias:
|
||||
sanctum.exe update -c
|
||||
|
||||
# Interaktives Upgrade durchführen (zeigt Release-Details und fragt nach Bestätigung):
|
||||
sanctum.exe upgrade
|
||||
|
||||
# Vollautomatisches Upgrade ohne Rückfragen (z. B. in Skripten):
|
||||
sanctum.exe upgrade -y
|
||||
|
||||
# Upgrade erzwingen (Neuinstallation / Reinstall derselben Version):
|
||||
sanctum.exe upgrade --force
|
||||
```
|
||||
|
||||
- **Plattformunabhängig**: Löst das Windows-Dateilock (`ERROR_ACCESS_DENIED`) via `self-replace` und unterstützt ebenso Linux (statisches musl ELF).
|
||||
- **Kryptografische Integrität**: Das heruntergeladene Binary wird vor dem Ersetzen zwingend gegen die offizielle `SHA256SUMS.txt` via SHA-256 verifiziert.
|
||||
- **Paketmanager-Harmonie**: Erkennt automatisch Installationen via Scoop (`scoop update sanctum`) oder Winget (`winget upgrade HaraldPansi.Sanctum`) und warnt den Benutzer.
|
||||
|
||||
---
|
||||
|
||||
### 10. Migration von Carrier-Format V1 auf V2 (Hidden Vault)
|
||||
|
||||
Mit Sanctum v0.9.0 wurde das **Carrier-Format V2** eingeführt, das die vorherige Begrenzung von ca. 7.000 Dateien im Hidden Vault aufhebt. Bestehende V1-Container werden transparent unterstützt und können wie folgt auf V2 aktualisiert werden:
|
||||
|
||||
1. **Sicherheits-Backup erstellen**:
|
||||
Erstellen Sie vor der Migration eine Sicherungskopie Ihres Containers:
|
||||
```powershell
|
||||
sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "C:\Pfad\tresor_backup.sanctum"
|
||||
# Oder manuell:
|
||||
Copy-Item "C:\Pfad\tresor.sanctum" "C:\Pfad\tresor_backup.sanctum"
|
||||
```
|
||||
|
||||
2. **Freispeicher prüfen**:
|
||||
Für die Migration wird mindestens ein freier Trägerblock (~1 MB) für die erste Inode-Page benötigt. Bei extrem vollen Trägerdateien (0 freie Blöcke) bricht Sanctum die Migration sicher ab (`Out of space`), ohne den V1-Container zu verändern oder zu beschädigen.
|
||||
|
||||
3. **Hidden Vault einbinden (`mount`)**:
|
||||
Öffnen Sie den Hidden Vault mit Ihrem Hidden-Passwort:
|
||||
```powershell
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
|
||||
```
|
||||
Sanctum erkennt automatisch das V1-Format, liest das bisherige Manifest ein und bereitet die V2-Struktur im Speicher vor.
|
||||
|
||||
4. **Migration durch Schreiboperation auslösen**:
|
||||
Die Migration auf die V2-On-Disk-Struktur wird beim nächsten Speichervorgang des Manifests automatisch vollzogen. Sie können dies durch Anlegen und sofortiges Löschen einer temporären Datei anstoßen:
|
||||
```powershell
|
||||
New-Item "S:\.migrate" -ItemType File
|
||||
Remove-Item "S:\.migrate"
|
||||
sanctum.exe unmount --drive S
|
||||
```
|
||||
Beim Aushängen (`unmount`) wird das neue Paged-Manifest (V2-Superblock + Inode-Pages) atomar auf die Trägerdatei geschrieben.
|
||||
|
||||
5. **Integrität verifizieren**:
|
||||
Überprüfen Sie den migrierten Container:
|
||||
```powershell
|
||||
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
Beim nächsten Mounten des Hidden Vaults zeigt Sanctum die dynamische Block- und Seitenstatistik des V2-Formats an.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ OpSec & Explorer Anti-Leak Shield
|
||||
|
||||
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
||||
|
||||
1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`).
|
||||
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts.
|
||||
3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab.
|
||||
4. **RAM-Paging-Schutz (`VirtualLock`)**: Verriegelt sensible Schlüsselstrukturen (`DEK`) im physischen RAM, um das Auslagern in `pagefile.sys` oder `swapfile.sys` durch den Windows Memory Manager zu verhindern.
|
||||
5. **ShellBag & Forensik-OpSec**: Über die Optionen `--no-open` oder `--stealth` kann das automatische Öffnen des Windows Explorers vollständig unterdrückt werden, um zu verhindern, dass Ordnernamen persistent im ShellBag-Cache (`UsrClass.dat`) protokolliert werden.
|
||||
6. **VFS Carrier-Schutz**: Die im Decoy-Tresor sichtbare Alibi-Trägerdatei wird im VFS gegen versehentliches Löschen, Überschreiben oder Umbenennen geschützt.
|
||||
7. **Power-Loss Crash-Consistency**: Atomare Bündelung von Block-Schreiboperationen und Metadaten-Größenaktualisierungen in einer SQLite-Transaktion verhindert Datenkorruption bei plötzlichem Stromausfall.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Windows-Kompatibilität & AV/EDR-Tuning
|
||||
|
||||
### Windows Defender SmartScreen ("Unbekannter Herausgeber")
|
||||
Wird die Binärdatei über einen Webbrowser heruntergeladen, versieht Windows sie mit dem *Mark-of-the-Web* (MotW). Da Open-Source-Projekte über kein kostenpflichtiges EV-Code-Signing-Zertifikat verfügen, zeigt Windows beim ersten Start den blauen SmartScreen-Dialog:
|
||||
- **Einmalige Freigabe im Dialog:** Klick auf **"Weitere Informationen"** $\to$ **"Trotzdem ausführen"**.
|
||||
- **Schnellfreigabe via PowerShell:**
|
||||
```powershell
|
||||
Unblock-File .\sanctum.exe
|
||||
```
|
||||
|
||||
### Windows Defender "Überwachter Ordnerzugriff" (Controlled Folder Access - CFA)
|
||||
Ist der erweiterte Ransomware-Schutz von Windows aktiv, kann Defender den Schreibzugriff auf Containerdateien in Standard-Benutzerordnern (`Dokumente`, `Desktop`) mit Fehler 5 (*Zugriff verweigert*) blockieren:
|
||||
- **Abhilfe:** Erlauben Sie `sanctum.exe` in den Windows-Sicherheitseinstellungen (*Viren- & Bedrohungsschutz* $\to$ *Ransomware-Schutz verwalten* $\to$ *App durch überwachten Ordnerzugriff zulassen*), oder speichern Sie Container in einem separaten Verzeichnis (z. B. `C:\Sanctum\`).
|
||||
|
||||
### I/O-Performance-Tuning
|
||||
Windows Defender scannt standardmäßig jeden modifizierten 1-MB-Chunk in Echtzeit. Durch das Hinzufügen der Dateiendung `*.sanctum` zu den Defender-Ausschlüssen lässt sich der I/O-Durchsatz bei großen Kopiervorgängen um 30–50% steigern.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Release Packaging & Distribution
|
||||
|
||||
Um ein Distributionspaket mit Release-Binary, Checksummen und Dokumentation zu bauen:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
||||
```
|
||||
|
||||
Das fertige Paket liegt in `dist/` bereit:
|
||||
- `dist/sanctum-v0.1.0-windows-x86_64.zip`
|
||||
Erzeugt:
|
||||
- `dist/sanctum-v0.7.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
||||
- `dist/sanctum-v0.7.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
||||
- `dist/SHA256SUMS.txt`
|
||||
- `dist/SHA256SUMS.txt.minisig` (Kryptografische Minisign-Release-Signatur)
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Rechtliche Hinweise, Compliance & Exportkontrolle
|
||||
|
||||
- **Kryptografie-Exportkontrolle (EAR & EU Dual-Use)**:
|
||||
Sanctum nutzt starke Kryptografie (AES-256-GCM, Argon2id). Da der Quellcode als freie Open-Source-Software jedermann unbeschränkt und unentgeltlich zur Verfügung steht, unterliegt er den Ausnahmetatbeständen gem. **15 C.F.R. § 742.15(b)** (US EAR) sowie der **General Software Note (GSN)** der Verordnung (EU) 2021/821.
|
||||
- **Dual-Vault-Architektur & Herausgabepflichten**:
|
||||
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) eine praktische Zweittresor-Funktion zum Schutz vor Schulterblick und beiläufiger Nötigung. Dies ist ein technischer Alltags-Schutzmechanismus, kein juristisches Schutzschild und keine forensisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme.
|
||||
- **Datenschutz & Telemetrie-Freiheit (DSGVO / GDPR)**:
|
||||
Sanctum arbeitet zu 100% offline und enthält keinerlei Code für Telemetrie, Analytics, Fehlerberichterstattung oder IP-Übertragungen (Art. 25 DSGVO *Privacy by Design*).
|
||||
- **Haftungsbeschränkung (EU/DACH Recht)**:
|
||||
Die Software wird als unentgeltliche FOSS-Software bereitgestellt. Gemäß **§ 521 BGB** (Schenkungsrecht) ist die Haftung des Entwicklers auf Vorsatz und grobe Fahrlässigkeit beschränkt. Der Anwender ist für die sichere Verwahrung von Schlüsseln und Backups selbst verantwortlich.
|
||||
- Ausführliche rechtliche Erläuterungen finden sich in [`LEGAL.md`](LEGAL.md).
|
||||
|
||||
---
|
||||
|
||||
## 📄 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).
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Sanctum Security Audit & Remediation Log (v0.9.1)
|
||||
|
||||
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, und Abschnitt 6 die Nachbesserungen der Minor Issues aus v0.9.1.
|
||||
|
||||
---
|
||||
|
||||
## Audit-Zusammenfassung
|
||||
|
||||
- **Zielversion:** Sanctum v0.9.1 (Basis v0.9.0)
|
||||
- **Behobene Findings:** 39 / 39 (100%)
|
||||
- **Test-Ergebnis:** 128 / 128 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) beim Mount 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), lückenlose Pfadvalidierung (`validate_node_name`), 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.500–7.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).
|
||||
|
||||
### 6. Sanctum v0.9.1 — Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
|
||||
- **Metadaten-MAC-Validierung beim Mount (K-01):**
|
||||
- `mount_container` validiert vor dem Bereitstellen des Dateisystems die Integrität der SQLite-Metadaten via `db.verify_metadata_mac_for_slot`.
|
||||
- Bei Manipulation bricht der Vorgang fail-closed ab; nachfolgende VFS-Operationen führen den MAC über den aktiven DEK fort.
|
||||
- **Lückenlose Pfadvalidierung (`validate_node_name`):**
|
||||
- Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`) validieren Knotennamen lückenlos gegen Windows-Reservierungen (`CON`, `PRN`, `AUX`, `NUL`, etc.) und verbotene Steuerzeichen.
|
||||
- **Dokumentation & Runtime-Warnung zu `--legacy-names`:**
|
||||
- Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung; explizite Warnung im CLI-Hilfetext, auf stderr und im Logging bei Aktivierung.
|
||||
- **Migration-Dokumentation (V1 → V2):**
|
||||
- Detaillierte 5-stufige Dokumentation für Anwender zur Konvertierung auf das Paged-Manifest-Format V2.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,382 @@
|
||||
# Third-Party Software Licenses & Attributions
|
||||
|
||||
Sanctum incorporates open-source software packages statically compiled into the binary.
|
||||
All included components are distributed under permissive open-source licenses.
|
||||
This document provides full attribution and license texts in compliance with
|
||||
Section 4 of the Apache License, Version 2.0, the MIT License, and BSD licenses.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dependency Inventory
|
||||
|
||||
| Crate | Version | License |
|
||||
| :--- | :--- | :--- |
|
||||
| `aead` | 0.5.2 | MIT OR Apache-2.0 |
|
||||
| `aes` | 0.8.4 | MIT OR Apache-2.0 |
|
||||
| `aes-gcm` | 0.10.3 | Apache-2.0 OR MIT |
|
||||
| `ahash` | 0.8.12 | MIT OR Apache-2.0 |
|
||||
| `aho-corasick` | 1.1.5 | Unlicense OR MIT |
|
||||
| `android_system_properties` | 0.1.6 | MIT OR Apache-2.0 |
|
||||
| `anstream` | 1.0.0 | MIT OR Apache-2.0 |
|
||||
| `anstyle` | 1.0.14 | MIT OR Apache-2.0 |
|
||||
| `anstyle-parse` | 1.0.0 | MIT OR Apache-2.0 |
|
||||
| `anstyle-query` | 1.1.5 | MIT OR Apache-2.0 |
|
||||
| `anstyle-wincon` | 3.0.11 | MIT OR Apache-2.0 |
|
||||
| `anyhow` | 1.0.104 | MIT OR Apache-2.0 |
|
||||
| `argon2` | 0.5.3 | MIT OR Apache-2.0 |
|
||||
| `arrayvec` | 0.7.8 | MIT OR Apache-2.0 |
|
||||
| `atomic-waker` | 1.1.2 | Apache-2.0 OR MIT |
|
||||
| `autocfg` | 1.5.1 | Apache-2.0 OR MIT |
|
||||
| `base64` | 0.22.1 | MIT OR Apache-2.0 |
|
||||
| `base64ct` | 1.8.3 | Apache-2.0 OR MIT |
|
||||
| `bip39` | 2.2.2 | CC0-1.0 |
|
||||
| `bitcoin_hashes` | 0.14.101 | CC0-1.0 |
|
||||
| `bitflags` | 2.13.1 | MIT OR Apache-2.0 |
|
||||
| `bitflags` | 1.3.2 | MIT/Apache-2.0 |
|
||||
| `blake2` | 0.10.6 | MIT OR Apache-2.0 |
|
||||
| `block` | 0.1.6 | MIT |
|
||||
| `block-buffer` | 0.10.4 | MIT OR Apache-2.0 |
|
||||
| `bumpalo` | 3.20.3 | MIT OR Apache-2.0 |
|
||||
| `bytes` | 1.12.1 | MIT |
|
||||
| `cc` | 1.4.5 | MIT OR Apache-2.0 |
|
||||
| `cfg-if` | 1.0.4 | MIT OR Apache-2.0 |
|
||||
| `chrono` | 0.4.45 | MIT OR Apache-2.0 |
|
||||
| `cipher` | 0.4.4 | MIT OR Apache-2.0 |
|
||||
| `clap` | 4.6.6 | MIT OR Apache-2.0 |
|
||||
| `clap_builder` | 4.6.6 | MIT OR Apache-2.0 |
|
||||
| `clap_derive` | 4.6.4 | MIT OR Apache-2.0 |
|
||||
| `clap_lex` | 1.1.0 | MIT OR Apache-2.0 |
|
||||
| `cocoa` | 0.25.0 | MIT OR Apache-2.0 |
|
||||
| `cocoa-foundation` | 0.1.2 | MIT OR Apache-2.0 |
|
||||
| `colorchoice` | 1.0.5 | MIT OR Apache-2.0 |
|
||||
| `core-foundation` | 0.9.4 | MIT OR Apache-2.0 |
|
||||
| `core-foundation-sys` | 0.8.7 | MIT OR Apache-2.0 |
|
||||
| `core-graphics` | 0.23.2 | MIT OR Apache-2.0 |
|
||||
| `core-graphics-types` | 0.1.3 | MIT OR Apache-2.0 |
|
||||
| `cpufeatures` | 0.2.17 | MIT OR Apache-2.0 |
|
||||
| `crypto-common` | 0.1.7 | MIT OR Apache-2.0 |
|
||||
| `ctr` | 0.9.2 | MIT OR Apache-2.0 |
|
||||
| `dav-server` | 0.11.0 | Apache-2.0 |
|
||||
| `derive-where` | 1.6.1 | MIT OR Apache-2.0 |
|
||||
| `digest` | 0.10.7 | MIT OR Apache-2.0 |
|
||||
| `displaydoc` | 0.2.7 | MIT OR Apache-2.0 |
|
||||
| `dyn-clone` | 1.0.20 | MIT OR Apache-2.0 |
|
||||
| `equivalent` | 1.0.2 | Apache-2.0 OR MIT |
|
||||
| `errno` | 0.3.14 | MIT OR Apache-2.0 |
|
||||
| `fallible-iterator` | 0.3.0 | MIT/Apache-2.0 |
|
||||
| `fallible-streaming-iterator` | 0.1.9 | MIT/Apache-2.0 |
|
||||
| `find-msvc-tools` | 0.1.12 | MIT OR Apache-2.0 |
|
||||
| `fnv` | 1.0.7 | Apache-2.0 / MIT |
|
||||
| `foreign-types` | 0.5.0 | MIT/Apache-2.0 |
|
||||
| `foreign-types-macros` | 0.2.4 | MIT OR Apache-2.0 |
|
||||
| `foreign-types-shared` | 0.3.1 | MIT OR Apache-2.0 |
|
||||
| `form_urlencoded` | 1.2.2 | MIT OR Apache-2.0 |
|
||||
| `futures-channel` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `futures-core` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `futures-macro` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `futures-sink` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `futures-task` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `futures-util` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `generic-array` | 0.14.7 | MIT |
|
||||
| `getrandom` | 0.4.3 | MIT OR Apache-2.0 |
|
||||
| `getrandom` | 0.2.17 | MIT OR Apache-2.0 |
|
||||
| `ghash` | 0.5.1 | Apache-2.0 OR MIT |
|
||||
| `h2` | 0.4.19 | MIT |
|
||||
| `hashbrown` | 0.17.1 | MIT OR Apache-2.0 |
|
||||
| `hashbrown` | 0.14.5 | MIT OR Apache-2.0 |
|
||||
| `hashlink` | 0.9.1 | MIT OR Apache-2.0 |
|
||||
| `headers` | 0.4.1 | MIT |
|
||||
| `headers-core` | 0.3.0 | MIT |
|
||||
| `heck` | 0.5.0 | MIT OR Apache-2.0 |
|
||||
| `hex` | 0.4.3 | MIT OR Apache-2.0 |
|
||||
| `hex-conservative` | 0.2.3 | CC0-1.0 |
|
||||
| `htmlescape` | 0.3.1 | Apache-2.0 / MIT / MPL-2.0 |
|
||||
| `http` | 1.5.0 | MIT OR Apache-2.0 |
|
||||
| `httparse` | 1.10.1 | MIT OR Apache-2.0 |
|
||||
| `http-body` | 1.1.0 | MIT |
|
||||
| `http-body-util` | 0.1.5 | MIT |
|
||||
| `httpdate` | 1.0.3 | MIT OR Apache-2.0 |
|
||||
| `hyper` | 1.11.1 | MIT |
|
||||
| `hyper-util` | 0.1.20 | MIT |
|
||||
| `iana-time-zone` | 0.1.65 | MIT OR Apache-2.0 |
|
||||
| `iana-time-zone-haiku` | 0.1.2 | MIT OR Apache-2.0 |
|
||||
| `icu_collections` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_locale_core` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_normalizer` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_normalizer_data` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_properties` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_properties_data` | 2.3.0 | Unicode-3.0 |
|
||||
| `icu_provider` | 2.3.1 | Unicode-3.0 |
|
||||
| `idna` | 1.1.0 | MIT OR Apache-2.0 |
|
||||
| `idna_adapter` | 1.2.2 | Apache-2.0 OR MIT |
|
||||
| `indexmap` | 2.14.2 | Apache-2.0 OR MIT |
|
||||
| `inout` | 0.1.4 | MIT OR Apache-2.0 |
|
||||
| `is_terminal_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
|
||||
| `itoa` | 1.0.18 | MIT OR Apache-2.0 |
|
||||
| `js-sys` | 0.3.105 | MIT OR Apache-2.0 |
|
||||
| `lazy_static` | 1.5.0 | MIT OR Apache-2.0 |
|
||||
| `libc` | 0.2.189 | MIT OR Apache-2.0 |
|
||||
| `libsqlite3-sys` | 0.30.1 | MIT |
|
||||
| `litemap` | 0.8.3 | Unicode-3.0 |
|
||||
| `lock_api` | 0.4.14 | MIT OR Apache-2.0 |
|
||||
| `log` | 0.4.34 | MIT OR Apache-2.0 |
|
||||
| `lz4_flex` | 0.11.6 | MIT |
|
||||
| `malloc_buf` | 0.0.6 | MIT |
|
||||
| `matchers` | 0.2.0 | MIT |
|
||||
| `memchr` | 2.8.3 | Unlicense OR MIT |
|
||||
| `mime` | 0.3.17 | MIT OR Apache-2.0 |
|
||||
| `mime_guess` | 2.0.5 | MIT |
|
||||
| `mio` | 1.2.3 | MIT |
|
||||
| `nu-ansi-term` | 0.50.3 | MIT |
|
||||
| `num-traits` | 0.2.19 | MIT OR Apache-2.0 |
|
||||
| `objc` | 0.2.7 | MIT OR Apache-2.0 |
|
||||
| `objc_id` | 0.1.1 | MIT |
|
||||
| `objc-foundation` | 0.1.1 | MIT OR Apache-2.0 |
|
||||
| `once_cell` | 1.21.4 | MIT OR Apache-2.0 |
|
||||
| `once_cell_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
|
||||
| `opaque-debug` | 0.3.1 | MIT OR Apache-2.0 |
|
||||
| `padlock` | 0.2.0 | MIT OR Apache-2.0 |
|
||||
| `parking_lot` | 0.12.5 | MIT OR Apache-2.0 |
|
||||
| `parking_lot_core` | 0.9.12 | MIT OR Apache-2.0 |
|
||||
| `password-hash` | 0.5.0 | MIT OR Apache-2.0 |
|
||||
| `percent-encoding` | 2.3.2 | MIT OR Apache-2.0 |
|
||||
| `pin-project-lite` | 0.2.17 | Apache-2.0 OR MIT |
|
||||
| `pkg-config` | 0.3.34 | MIT OR Apache-2.0 |
|
||||
| `polyval` | 0.6.2 | Apache-2.0 OR MIT |
|
||||
| `potential_utf` | 0.1.6 | Unicode-3.0 |
|
||||
| `ppv-lite86` | 0.2.21 | MIT OR Apache-2.0 |
|
||||
| `proc-macro2` | 1.0.107 | MIT OR Apache-2.0 |
|
||||
| `quote` | 1.0.47 | MIT OR Apache-2.0 |
|
||||
| `rand` | 0.8.8 | MIT OR Apache-2.0 |
|
||||
| `rand_chacha` | 0.3.1 | MIT OR Apache-2.0 |
|
||||
| `rand_core` | 0.6.4 | MIT OR Apache-2.0 |
|
||||
| `redox_syscall` | 0.5.18 | MIT OR Apache-2.0 |
|
||||
| `r-efi` | 6.0.0 | MIT OR Apache-2.0 |
|
||||
| `regex-automata` | 0.4.18 | MIT OR Apache-2.0 |
|
||||
| `regex-syntax` | 0.8.11 | MIT OR Apache-2.0 |
|
||||
| `rpassword` | 7.5.4 | Apache-2.0 |
|
||||
| `rtoolbox` | 0.0.6 | Apache-2.0 |
|
||||
| `rusqlite` | 0.32.1 | MIT |
|
||||
| `rustversion` | 1.0.23 | MIT OR Apache-2.0 |
|
||||
| `scopeguard` | 1.2.0 | MIT OR Apache-2.0 |
|
||||
| `serde` | 1.0.229 | MIT OR Apache-2.0 |
|
||||
| `serde_core` | 1.0.229 | MIT OR Apache-2.0 |
|
||||
| `serde_derive` | 1.0.229 | MIT OR Apache-2.0 |
|
||||
| `serde_json` | 1.0.151 | MIT OR Apache-2.0 |
|
||||
| `sha1` | 0.10.7 | MIT OR Apache-2.0 |
|
||||
| `sharded-slab` | 0.1.7 | MIT |
|
||||
| `shlex` | 2.0.1 | MIT OR Apache-2.0 |
|
||||
| `signal-hook-registry` | 1.4.8 | MIT OR Apache-2.0 |
|
||||
| `slab` | 0.4.12 | MIT |
|
||||
| `smallvec` | 1.16.0 | MIT OR Apache-2.0 |
|
||||
| `socket2` | 0.6.5 | MIT OR Apache-2.0 |
|
||||
| `stable_deref_trait` | 1.2.1 | MIT OR Apache-2.0 |
|
||||
| `strsim` | 0.11.1 | MIT |
|
||||
| `subtle` | 2.6.1 | BSD-3-Clause |
|
||||
| `syn` | 3.0.5 | MIT OR Apache-2.0 |
|
||||
| `syn` | 2.0.119 | MIT OR Apache-2.0 |
|
||||
| `synstructure` | 0.13.2 | MIT |
|
||||
| `thiserror` | 2.0.20 | MIT OR Apache-2.0 |
|
||||
| `thiserror-impl` | 2.0.20 | MIT OR Apache-2.0 |
|
||||
| `thread_local` | 1.1.10 | MIT OR Apache-2.0 |
|
||||
| `tinystr` | 0.8.4 | Unicode-3.0 |
|
||||
| `tinyvec` | 1.13.2 | Zlib OR Apache-2.0 OR MIT |
|
||||
| `tinyvec_macros` | 0.1.1 | MIT OR Apache-2.0 OR Zlib |
|
||||
| `tokio` | 1.53.1 | MIT |
|
||||
| `tokio-macros` | 2.7.2 | MIT |
|
||||
| `tokio-util` | 0.7.19 | MIT |
|
||||
| `tracing` | 0.1.44 | MIT |
|
||||
| `tracing-attributes` | 0.1.31 | MIT |
|
||||
| `tracing-core` | 0.1.36 | MIT |
|
||||
| `tracing-log` | 0.2.0 | MIT |
|
||||
| `tracing-subscriber` | 0.3.23 | MIT |
|
||||
| `tray-item` | 0.10.0 | MIT |
|
||||
| `twox-hash` | 2.1.4 | MIT |
|
||||
| `typenum` | 1.20.1 | MIT OR Apache-2.0 |
|
||||
| `unicase` | 2.9.0 | MIT OR Apache-2.0 |
|
||||
| `unicode-ident` | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 |
|
||||
| `unicode-normalization` | 0.1.25 | MIT OR Apache-2.0 |
|
||||
| `universal-hash` | 0.5.1 | MIT OR Apache-2.0 |
|
||||
| `url` | 2.5.8 | MIT OR Apache-2.0 |
|
||||
| `utf8_iter` | 1.0.4 | Apache-2.0 OR MIT |
|
||||
| `utf8parse` | 0.2.2 | Apache-2.0 OR MIT |
|
||||
| `uuid` | 1.26.0 | Apache-2.0 OR MIT |
|
||||
| `valuable` | 0.1.1 | MIT OR Apache-2.0 |
|
||||
| `vcpkg` | 0.2.15 | MIT/Apache-2.0 |
|
||||
| `version_check` | 0.9.5 | MIT/Apache-2.0 |
|
||||
| `wasi` | 0.11.1+wasi-snapshot-preview1 | MIT OR Apache-2.0 |
|
||||
| `wasm-bindgen` | 0.2.128 | MIT OR Apache-2.0 |
|
||||
| `wasm-bindgen-macro` | 0.2.128 | MIT OR Apache-2.0 |
|
||||
| `wasm-bindgen-macro-support` | 0.2.128 | MIT OR Apache-2.0 |
|
||||
| `wasm-bindgen-shared` | 0.2.128 | MIT OR Apache-2.0 |
|
||||
| `windows_aarch64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_aarch64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_i686_gnu` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_i686_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_i686_msvc` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_x86_64_gnu` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_x86_64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows_x86_64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `windows-core` | 0.62.2 | MIT OR Apache-2.0 |
|
||||
| `windows-implement` | 0.60.2 | MIT OR Apache-2.0 |
|
||||
| `windows-interface` | 0.59.3 | MIT OR Apache-2.0 |
|
||||
| `windows-link` | 0.2.1 | MIT OR Apache-2.0 |
|
||||
| `windows-result` | 0.4.1 | MIT OR Apache-2.0 |
|
||||
| `windows-strings` | 0.5.1 | MIT OR Apache-2.0 |
|
||||
| `windows-sys` | 0.61.2 | MIT OR Apache-2.0 |
|
||||
| `windows-sys` | 0.52.0 | MIT OR Apache-2.0 |
|
||||
| `windows-targets` | 0.52.6 | MIT OR Apache-2.0 |
|
||||
| `writeable` | 0.6.4 | Unicode-3.0 |
|
||||
| `xml` | 1.4.0 | MIT |
|
||||
| `xml-rs` | 1.0.0 | MIT |
|
||||
| `xmltree` | 0.12.0 | MIT |
|
||||
| `yoke` | 0.8.3 | Unicode-3.0 |
|
||||
| `yoke-derive` | 0.8.2 | Unicode-3.0 |
|
||||
| `zerocopy` | 0.8.56 | BSD-2-Clause OR Apache-2.0 OR MIT |
|
||||
| `zerocopy-derive` | 0.8.56 | MIT OR Apache-2.0 |
|
||||
| `zerofrom` | 0.1.8 | Unicode-3.0 |
|
||||
| `zerofrom-derive` | 0.1.7 | Unicode-3.0 |
|
||||
| `zeroize` | 1.9.0 | Apache-2.0 OR MIT |
|
||||
| `zeroize_derive` | 1.5.0 | Apache-2.0 OR MIT |
|
||||
| `zerotrie` | 0.2.5 | Unicode-3.0 |
|
||||
| `zerovec` | 0.11.8 | Unicode-3.0 |
|
||||
| `zerovec-derive` | 0.11.6 | Unicode-3.0 |
|
||||
| `zmij` | 1.0.23 | MIT |
|
||||
|
||||
---
|
||||
|
||||
## 2. License Texts
|
||||
|
||||
### The MIT License
|
||||
```text
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### Apache License, Version 2.0
|
||||
```text
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file.
|
||||
```
|
||||
|
||||
### 3-Clause BSD License
|
||||
```text
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
### SQLite Copyright & Blessing (Public Domain)
|
||||
```text
|
||||
The author disclaims copyright to this source code. In place of a legal notice,
|
||||
here is a blessing:
|
||||
|
||||
May you do good and not evil.
|
||||
May you find forgiveness for yourself and forgive others.
|
||||
May you share freely, never taking more than you give.
|
||||
```
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Threat Model & Sicherheitsarchitektur von Sanctum
|
||||
|
||||
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.1** 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. |
|
||||
| **Dateinamen-Swap-Angriffe** | Angreifer vertauscht verschlüsselte Dateinamen zwischen Verzeichnissen. | AAD-Bindung an `parent_id` bei AES-256-GCM Verschlüsselung; Warnung vor `--legacy-names` (deaktiviert diesen Schutz). |
|
||||
| **Path-Traversal & Gerätenamen** | Angreifer schleust Pfad-Traversal (`..`), reservierte Namen (`CON`, `PRN`) oder Null-Bytes ein. | Strikte Validierung (`validate_node_name`) in allen Schichten (`storage.rs`, `vfs.rs`, `carrier.rs`, `sync.rs`). |
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dateinamen-Sicherheit und das `--legacy-names`-Flag
|
||||
|
||||
### 7.1 AAD-Bindung von Dateinamen
|
||||
In Sanctum werden Dateinamen mit AES-256-GCM verschlüsselt. Um Swap-Angriffe zu verhindern (bei denen ein Angreifer mit Dateisystem-Zugriff verschlüsselte Dateinamen zwischen Verzeichnissen austauscht), wird die `parent_id` als zusätzliche authentifizierte Daten (AAD) an die Verschlüsselung gebunden.
|
||||
|
||||
### 7.2 Sicherheitsrisiko des `--legacy-names`-Flags
|
||||
Frühere Versionen von Sanctum (vor v0.8.0) verwendeten leere AAD für Dateinamen. Das Flag `--legacy-names` erlaubt die Entschlüsselung von Dateinamen ohne AAD-Bindung als Fallback.
|
||||
- **Risiko**: Bei aktiviertem `--legacy-names` ist der Schutz vor Vertauschen von Dateinamen aufgehoben. Ein Angreifer könnte Dateien manipulieren, indem er verschlüsselte Namen zwischen verschiedenen Verzeichnissen vertauscht.
|
||||
- **Empfehlung**: Dieses Flag darf **ausschließlich** zur einmaligen Migration von Altdaten verwendet werden. Im regulären Betrieb muss es deaktiviert bleiben.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 546 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 830 KiB |
@@ -0,0 +1,28 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=assets/icon.ico");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "windows" {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let rc_path = format!("{}/sanctum.rc", out_dir);
|
||||
let res_path = format!("{}/sanctum.res", out_dir);
|
||||
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let ico_path = format!("{}/assets/icon.ico", manifest_dir).replace('\\', "/");
|
||||
|
||||
if std::path::Path::new(&ico_path).exists() {
|
||||
let rc_content = format!("1 ICON \"{}\"\n", ico_path);
|
||||
if std::fs::write(&rc_path, rc_content).is_ok() {
|
||||
let status = std::process::Command::new("windres")
|
||||
.args(["-i", &rc_path, "-O", "coff", "-o", &res_path])
|
||||
.status();
|
||||
if let Ok(s) = status {
|
||||
if s.success() {
|
||||
println!("cargo:rustc-link-arg={}", res_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": "0.9.1",
|
||||
"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.9.1/sanctum-v0.9.1-windows-x86_64.zip",
|
||||
"hash": "0409d5e67c173983693456620b217e9498dcdc1ae54a6bc67aa8e368ee3d0883",
|
||||
"bin": "sanctum.exe"
|
||||
}
|
||||
},
|
||||
"checkver": {
|
||||
"url": "https://gitea.pansi.eu/api/v1/repos/harald/sanctum/releases/latest",
|
||||
"jsonpath": "$.tag_name",
|
||||
"regex": "v([\\d.]+)"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v$version/sanctum-v$version-windows-x86_64.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||
PackageIdentifier: HaraldPansi.Sanctum
|
||||
PackageVersion: 0.9.1
|
||||
PackageName: Sanctum
|
||||
Publisher: Harald Pansi
|
||||
PublisherUrl: https://gitea.pansi.eu/harald
|
||||
PublisherSupportUrl: https://gitea.pansi.eu/harald/sanctum/issues
|
||||
Author: Harald Pansi
|
||||
License: MIT
|
||||
LicenseUrl: https://gitea.pansi.eu/harald/sanctum/src/branch/main/LICENSE
|
||||
ShortDescription: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV
|
||||
Description: Sanctum ist eine eigenständige, speichersichere CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (.sanctum) unter Windows 10/11 im reinen Userland verwaltet. 100% Userland, keine Adminrechte, keine Treiber.
|
||||
Moniker: sanctum
|
||||
Tags:
|
||||
- encryption
|
||||
- security
|
||||
- privacy
|
||||
- webdav
|
||||
- container
|
||||
- plausible-deniability
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.1
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: sanctum.exe
|
||||
PortableCommandAlias: sanctum
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.1/sanctum-v0.9.1-windows-x86_64.zip
|
||||
InstallerSha256: 0409d5e67c173983693456620b217e9498dcdc1ae54a6bc67aa8e368ee3d0883
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
@@ -0,0 +1,2 @@
|
||||
untrusted comment: minisign public key E735BF824F9856AC
|
||||
RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU
|
||||
@@ -0,0 +1,147 @@
|
||||
param (
|
||||
[switch]$SkipBuild = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Repository-Wurzelverzeichnis ermitteln
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
# Toolchain-Pfade sicherstellen (Zig + Cargo Bin)
|
||||
$ZigDir = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\zig.zig_Microsoft.Winget.Source_8wekyb3d8bbwe\zig-x86_64-windows-0.16.0"
|
||||
$env:PATH = "$ZigDir;C:\Users\pansih\.cargo\bin;C:\Windows\System32;" + $env:PATH
|
||||
|
||||
# Version aus Cargo.toml auslesen
|
||||
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
|
||||
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
|
||||
$Version = $matches[1]
|
||||
} else {
|
||||
Write-Error "Konnte Versionsnummer nicht aus Cargo.toml ermitteln."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Sanctum Linux Release Packaging v$Version (x86_64 musl)" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
# 1. Linux Release-Binary bauen
|
||||
$Target = "x86_64-unknown-linux-musl"
|
||||
$BinaryPath = Join-Path $ProjectRoot "target\$Target\release\sanctum"
|
||||
|
||||
if (-not $SkipBuild -or -not (Test-Path $BinaryPath)) {
|
||||
Write-Host "`n[1/3] Kompiliere statisches Linux-Binary via cargo-zigbuild..." -ForegroundColor Yellow
|
||||
cargo-zigbuild.exe zigbuild --target $Target --release
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Linux-Build fehlgeschlagen!"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Write-Host "[OK] Linux-Binary erfolgreich gebaut." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n[1/3] Build uebersprungen (-SkipBuild)." -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
# 2. Release-Verzeichnisstruktur vorbereiten
|
||||
Write-Host "`n[2/3] Erstelle Linux-Distributionspaket..." -ForegroundColor Yellow
|
||||
|
||||
$DistDir = Join-Path $ProjectRoot "dist"
|
||||
if (-not (Test-Path $DistDir)) {
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$PackageName = "sanctum-v$Version-linux-x86_64"
|
||||
$StagingDir = Join-Path $DistDir $PackageName
|
||||
$TarGzFile = Join-Path $DistDir "$PackageName.tar.gz"
|
||||
|
||||
if (Test-Path $StagingDir) {
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
|
||||
|
||||
# Dateien kopieren
|
||||
Copy-Item $BinaryPath -Destination (Join-Path $StagingDir "sanctum") -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "README.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "LICENSE") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") -Destination $StagingDir -Force
|
||||
|
||||
# .tar.gz Archiv erstellen mit Windows nativem bsdtar
|
||||
if (Test-Path $TarGzFile) {
|
||||
Remove-Item $TarGzFile -Force
|
||||
}
|
||||
|
||||
tar.exe -czf $TarGzFile -C $DistDir $PackageName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Fehler beim Erstellen des tar.gz-Archivs!"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Staging-Verzeichnis bereinigen
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
|
||||
Write-Host "[OK] Archiv erstellt: $TarGzFile" -ForegroundColor Green
|
||||
|
||||
# 3. SHA-256 Pruefsummen berechnen und aktualisieren
|
||||
Write-Host "`n[3/3] Aktualisiere SHA-256 Pruefsummen..." -ForegroundColor Yellow
|
||||
|
||||
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
|
||||
$TarHash = (Get-FileHash -Path $TarGzFile -Algorithm SHA256).Hash.ToLower()
|
||||
$ElfHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
# Existierende Pruefsummen lesen und um Linux-Assets ergaenzen/aktualisieren
|
||||
$ExistingLines = if (Test-Path $ChecksumFile) {
|
||||
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.tar\.gz" -and $_ -notmatch "\s+sanctum$" }
|
||||
} else {
|
||||
@()
|
||||
}
|
||||
|
||||
$AllLines = @()
|
||||
$AllLines += $ExistingLines
|
||||
$AllLines += "$TarHash $PackageName.tar.gz"
|
||||
$AllLines += "$ElfHash sanctum"
|
||||
|
||||
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
|
||||
# Minisign Signatur aktualisieren (S-02)
|
||||
Write-Host "`nSigniere aktualisierte Pruefsummen mit Minisign..." -ForegroundColor Yellow
|
||||
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
|
||||
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
|
||||
$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 -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)
|
||||
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
|
||||
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
Write-Host " Sanctum Linux Release v$Version erfolgreich gepackt!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Archiv: $TarGzFile ($TarSizeMB MB)"
|
||||
Write-Host " TAR SHA-256: $TarHash"
|
||||
Write-Host " ELF SHA-256: $ElfHash"
|
||||
Write-Host " Checksum-File: $ChecksumFile"
|
||||
if (Test-Path $SigFile) {
|
||||
Write-Host " Minisign Sig: $SigFile"
|
||||
}
|
||||
Write-Host "`nBereit fuer Gitea Release."
|
||||
@@ -55,16 +55,24 @@ $PackageName = "sanctum-v$Version-windows-x86_64"
|
||||
$StagingDir = Join-Path $DistDir $PackageName
|
||||
$ZipFile = Join-Path $DistDir "$PackageName.zip"
|
||||
|
||||
if (Test-Path $DistDir) {
|
||||
Remove-Item $DistDir -Recurse -Force
|
||||
if (-not (Test-Path $DistDir)) {
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
}
|
||||
if (Test-Path $StagingDir) {
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
|
||||
|
||||
$ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe"
|
||||
Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
|
||||
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") (Join-Path $StagingDir "QUICKSTART.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") (Join-Path $StagingDir "INSTALL.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
|
||||
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") (Join-Path $StagingDir "LEGAL.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") (Join-Path $StagingDir "THIRD_PARTY_LICENSES.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse
|
||||
|
||||
# ZIP-Archiv schnueren
|
||||
Compress-Archive -Path "$StagingDir\*" -DestinationPath $ZipFile -Force
|
||||
@@ -79,10 +87,45 @@ $ZipHash = (Get-FileHash -Path $ZipFile -Algorithm SHA256).Hash.ToLower()
|
||||
$ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
|
||||
@("$ZipHash $PackageName.zip", "$ExeHash sanctum.exe") | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
$ExistingLines = if (Test-Path $ChecksumFile) {
|
||||
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.zip" -and $_ -notmatch "\s+sanctum\.exe$" }
|
||||
} else {
|
||||
@()
|
||||
}
|
||||
|
||||
$AllLines = @()
|
||||
$AllLines += "$ZipHash $PackageName.zip"
|
||||
$AllLines += "$ExeHash sanctum.exe"
|
||||
$AllLines += $ExistingLines
|
||||
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
|
||||
Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green
|
||||
|
||||
# 5. Minisign Signatur generieren (S-02)
|
||||
Write-Host "`n[5/5] Signiere Pruefsummen mit Minisign..." -ForegroundColor Yellow
|
||||
$MinisignExe = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\jedisct1.minisign_Microsoft.Winget.Source_8wekyb3d8bbwe\minisign-win64\x86_64\minisign.exe"
|
||||
$KeyFile = Join-Path $ProjectRoot "sanctum-release.key"
|
||||
$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 -t "version:$Version"
|
||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $SigFile)) {
|
||||
Write-Host "[OK] Minisign-Signatur erstellt: 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 (Signatur ist obligatorisch)."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Error "sanctum-release.key nicht gefunden ($KeyFile)! Release-Erstellung abgebrochen (Signatur ist obligatorisch)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Abschluss-Zusammenfassung
|
||||
$ZipSize = (Get-Item $ZipFile).Length / 1MB
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
@@ -92,4 +135,7 @@ Write-Host " Archiv: dist\$PackageName.zip ($([math]::Round($ZipSize, 2)
|
||||
Write-Host " ZIP SHA-256: $ZipHash"
|
||||
Write-Host " EXE SHA-256: $ExeHash"
|
||||
Write-Host " Checksum-File: dist\SHA256SUMS.txt"
|
||||
if (Test-Path $SigFile) {
|
||||
Write-Host " Minisign Sig: dist\SHA256SUMS.txt.minisig"
|
||||
}
|
||||
Write-Host "`nBereit fuer Gitea Release / Verteilung.`n"
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
param (
|
||||
[string]$Token,
|
||||
[string]$GiteaUrl = "https://gitea.pansi.eu",
|
||||
[string]$Owner = "harald",
|
||||
[string]$Repo = "sanctum"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
|
||||
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
|
||||
$Version = $matches[1]
|
||||
} else {
|
||||
Write-Error "Version nicht gefunden."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$TagName = "v$Version"
|
||||
$DistDir = Join-Path $ProjectRoot "dist"
|
||||
$ZipFile = Join-Path $DistDir "sanctum-$TagName-windows-x86_64.zip"
|
||||
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
|
||||
|
||||
if (-not (Test-Path $ZipFile)) {
|
||||
Write-Host "Dist-Paket existiert noch nicht. Baue Paket..." -ForegroundColor Yellow
|
||||
& (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) {
|
||||
Get-Content $EnvFile | ForEach-Object {
|
||||
if ($_ -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$') {
|
||||
$val = $matches[2].Trim('"', "'")
|
||||
[System.Environment]::SetEnvironmentVariable($matches[1], $val, "Process")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Token ermitteln (Parameter -> Environment -> Prompt)
|
||||
if (-not $Token) {
|
||||
if ($env:GITEA_TOKEN) {
|
||||
$Token = $env:GITEA_TOKEN
|
||||
} else {
|
||||
$SecureToken = Read-Host "Gitea Personal Access Token (mit 'repo'-Scope)" -AsSecureString
|
||||
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureToken)
|
||||
$Token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Token -or $Token.Trim().Length -eq 0) {
|
||||
Write-Error "Kein Token angegeben. Abgebrochen."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Veroeffentliche Release $TagName auf Gitea" -ForegroundColor Cyan
|
||||
Write-Host " Repository: $Owner/$Repo ($GiteaUrl)" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
$Headers = @{
|
||||
"Authorization" = "token $Token"
|
||||
"Accept" = "application/json"
|
||||
}
|
||||
|
||||
# Pruefen ob Release bereits existiert
|
||||
$ExistingReleases = try {
|
||||
Invoke-RestMethod -Uri "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases" -Headers $Headers -Method Get
|
||||
} catch {
|
||||
@()
|
||||
}
|
||||
|
||||
$Release = $ExistingReleases | Where-Object { $_.tag_name -eq $TagName }
|
||||
|
||||
if (-not $Release) {
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
$Changelog = [System.IO.File]::ReadAllText((Join-Path $ProjectRoot "CHANGELOG.md"), $Utf8NoBom)
|
||||
$PayloadPath = Join-Path $DistDir "release_req.json"
|
||||
$Payload = @{
|
||||
tag_name = $TagName
|
||||
name = "Sanctum $TagName (Windows x86_64)"
|
||||
body = $Changelog
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 5
|
||||
[System.IO.File]::WriteAllText($PayloadPath, $Payload, $Utf8NoBom)
|
||||
|
||||
|
||||
$CreateResp = & curl.exe -s -X POST "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases" `
|
||||
-H "Authorization: token $Token" `
|
||||
-H "Content-Type: application/json; charset=utf-8" `
|
||||
--data-binary "@$PayloadPath"
|
||||
|
||||
Remove-Item $PayloadPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$Release = $CreateResp | ConvertFrom-Json
|
||||
if (-not $Release.id) {
|
||||
Write-Error "Fehler beim Erstellen des Releases: $CreateResp"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK] Release $TagName auf Gitea angelegt (ID: $($Release.id))." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[INFO] Release $TagName existiert bereits (ID: $($Release.id))." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
|
||||
$UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets"
|
||||
$AuthHeader = "Authorization: token $Token"
|
||||
|
||||
# Hilfsfunktion zum sauberen Hochladen/Ersetzen von Assets
|
||||
function Upload-ReleaseAsset {
|
||||
param (
|
||||
[string]$FilePath,
|
||||
[string]$AssetName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Lade $AssetName hoch..." -ForegroundColor Cyan
|
||||
|
||||
# Vorhandenes Asset mit gleichem Namen ermitteln und ggf. loeschen
|
||||
$ExistingAssets = try {
|
||||
Invoke-RestMethod -Uri $UploadUrl -Headers $Headers -Method Get
|
||||
} catch {
|
||||
@()
|
||||
}
|
||||
$Duplicate = $ExistingAssets | Where-Object { $_.name -eq $AssetName }
|
||||
if ($Duplicate) {
|
||||
Write-Host " [i] Ersetze existierendes Asset '$AssetName' (ID: $($Duplicate.id))..." -ForegroundColor DarkYellow
|
||||
$DeleteUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets/$($Duplicate.id)"
|
||||
Invoke-RestMethod -Uri $DeleteUrl -Headers $Headers -Method Delete | Out-Null
|
||||
}
|
||||
|
||||
$PathNorm = $FilePath.Replace('\', '/')
|
||||
$TargetUrl = "${UploadUrl}?name=$AssetName"
|
||||
$Result = & curl.exe -sS -X POST $TargetUrl -H $AuthHeader -F "attachment=@$PathNorm"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Upload von $AssetName fehlgeschlagen: $Result"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK] $AssetName erfolgreich hochgeladen." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Windows-Assets hochladen
|
||||
Upload-ReleaseAsset -FilePath $ZipFile -AssetName "sanctum-$TagName-windows-x86_64.zip"
|
||||
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
|
||||
Upload-ReleaseAsset -FilePath $ExeFile -AssetName "sanctum.exe"
|
||||
|
||||
# Linux-Assets hochladen
|
||||
$LinuxTar = Join-Path $DistDir "sanctum-$TagName-linux-x86_64.tar.gz"
|
||||
Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_64.tar.gz"
|
||||
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
|
||||
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
|
||||
|
||||
# SHA256SUMS.txt & Minisign-Signatur hochladen
|
||||
Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
|
||||
$SigFile = Join-Path $DistDir "SHA256SUMS.txt.minisig"
|
||||
Upload-ReleaseAsset -FilePath $SigFile -AssetName "SHA256SUMS.txt.minisig"
|
||||
|
||||
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
Write-Host " Release $TagName erfolgreich auf Gitea veroeffentlicht!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " URL: $GiteaUrl/$Owner/$Repo/releases/tag/$TagName`n"
|
||||
+1784
File diff suppressed because it is too large
Load Diff
+1192
-38
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
||||
pub mod carrier;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod pathutil;
|
||||
pub mod platform;
|
||||
pub mod recovery;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod ui;
|
||||
pub mod upgrade;
|
||||
pub mod verify;
|
||||
pub mod vfs;
|
||||
pub mod windows;
|
||||
|
||||
+1696
-58
File diff suppressed because it is too large
Load Diff
+978
-148
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Plattform-Abstraktionsschicht für Sanctum.
|
||||
//!
|
||||
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
|
||||
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
|
||||
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
|
||||
|
||||
pub use crate::windows::*;
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::{
|
||||
decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
|
||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
||||
|
||||
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
|
||||
pub const CURRENT_BACKUP_VERSION: u32 = 1;
|
||||
|
||||
/// Struktur für einen gesicherten Header-Slot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotBackup {
|
||||
pub slot_id: u32,
|
||||
pub version: u32,
|
||||
pub kdf_salt_hex: String,
|
||||
pub kdf_params: KdfParams,
|
||||
pub wrapped_dek_hex: String,
|
||||
pub header_nonce_hex: String,
|
||||
pub header_tag_hex: String,
|
||||
}
|
||||
|
||||
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeaderBackup {
|
||||
pub magic: String,
|
||||
pub backup_version: u32,
|
||||
pub container_format_version: u32,
|
||||
pub kdf_salt_hex: String,
|
||||
pub kdf_params: KdfParams,
|
||||
pub wrapped_dek_hex: String,
|
||||
pub header_nonce_hex: String,
|
||||
pub header_tag_hex: String,
|
||||
#[serde(default)]
|
||||
pub slots: Vec<SlotBackup>,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl HeaderBackup {
|
||||
pub fn from_meta(meta: &ContainerMeta) -> Self {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let slots: Vec<SlotBackup> = meta
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| SlotBackup {
|
||||
slot_id: s.slot_id,
|
||||
version: s.version,
|
||||
kdf_salt_hex: hex::encode(s.kdf_salt),
|
||||
kdf_params: s.kdf_params.clone(),
|
||||
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
|
||||
header_nonce_hex: hex::encode(s.header_nonce),
|
||||
header_tag_hex: hex::encode(s.header_tag),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
magic: HEADER_BACKUP_MAGIC.to_string(),
|
||||
backup_version: CURRENT_BACKUP_VERSION,
|
||||
container_format_version: meta.version,
|
||||
kdf_salt_hex: hex::encode(meta.kdf_salt),
|
||||
kdf_params: meta.kdf_params.clone(),
|
||||
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
|
||||
header_nonce_hex: hex::encode(meta.header_nonce),
|
||||
header_tag_hex: hex::encode(meta.header_tag),
|
||||
slots,
|
||||
created_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_meta(&self) -> Result<ContainerMeta> {
|
||||
if self.magic != HEADER_BACKUP_MAGIC {
|
||||
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
kdf_salt.copy_from_slice(&salt_bytes);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_bytes);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
header_tag.copy_from_slice(&tag_bytes);
|
||||
|
||||
slots.push(SlotMeta {
|
||||
slot_id: s.slot_id,
|
||||
version: s.version,
|
||||
kdf_salt,
|
||||
kdf_params: s.kdf_params.clone(),
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
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")?;
|
||||
if salt_bytes.len() != 16 {
|
||||
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);
|
||||
|
||||
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
|
||||
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_bytes);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
header_tag.copy_from_slice(&tag_bytes);
|
||||
|
||||
let slot0 = SlotMeta {
|
||||
slot_id: 0,
|
||||
version: self.container_format_version,
|
||||
kdf_salt,
|
||||
kdf_params: self.kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce,
|
||||
header_tag,
|
||||
};
|
||||
slots.push(slot0);
|
||||
}
|
||||
|
||||
let slot0 = &slots[0];
|
||||
Ok(ContainerMeta {
|
||||
version: slot0.version,
|
||||
kdf_salt: slot0.kdf_salt,
|
||||
kdf_params: slot0.kdf_params.clone(),
|
||||
wrapped_dek: slot0.wrapped_dek.clone(),
|
||||
header_nonce: slot0.header_nonce,
|
||||
header_tag: slot0.header_tag,
|
||||
slots,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
);
|
||||
}
|
||||
|
||||
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 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()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stellt den Container-Header aus einer Sicherungsdatei (.sanctum.hdr) wieder her.
|
||||
pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
|
||||
if !backup_path.exists() {
|
||||
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 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")?;
|
||||
|
||||
db.restore_meta(&meta)
|
||||
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
|
||||
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ermittelt automatisch, ob ein Notfallschlüssel zu Slot 0 (Standard/Decoy) oder Slot 1 (Hidden Vault) gehört (S-05).
|
||||
pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> Result<u32> {
|
||||
if let Ok(Some(carrier_id)) = db.find_carrier_node_id() {
|
||||
if let Ok(Some(chunk0)) = db.read_chunk(carrier_id, 0) {
|
||||
let is_dek0 = decrypt_chunk(
|
||||
dek,
|
||||
carrier_id,
|
||||
0,
|
||||
&chunk0.ciphertext,
|
||||
&chunk0.nonce,
|
||||
&chunk0.tag,
|
||||
version,
|
||||
chunk0.generation,
|
||||
)
|
||||
.is_ok();
|
||||
if is_dek0 {
|
||||
return Ok(0);
|
||||
} else {
|
||||
return Ok(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
|
||||
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
||||
pub fn restore_slot_from_recovery_key(
|
||||
container_path: &Path,
|
||||
recovery_key: &str,
|
||||
new_password: &str,
|
||||
target_slot_id: u32,
|
||||
dek_0: Option<&[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
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")?;
|
||||
|
||||
// 2. Neuen KEK mit frischem Salt ableiten
|
||||
let mut salt = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
|
||||
|
||||
let kdf_params = KdfParams::default();
|
||||
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 carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||
|
||||
let mut existing_slots = db.read_slots().unwrap_or_default();
|
||||
|
||||
let new_slot = if target_slot_id == 1 {
|
||||
// Für Slot 1 wird der echte DEK_0 benötigt (S-05)
|
||||
let dek_0_bytes = dek_0.ok_or_else(|| {
|
||||
anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
|
||||
})?;
|
||||
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
|
||||
SlotMeta {
|
||||
slot_id: 1,
|
||||
version: FORMAT_VERSION,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped,
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
}
|
||||
} else {
|
||||
// Slot 0 (Standard / Decoy Vault) mit 40 Bytes für Modell A
|
||||
let (wrapped, nonce, tag) = wrap_slot0_payload(&kek, &dek, carrier_node_id)?;
|
||||
SlotMeta {
|
||||
slot_id: 0,
|
||||
version: FORMAT_VERSION,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped,
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
}
|
||||
};
|
||||
|
||||
// Slot ersetzen bzw. einfügen
|
||||
existing_slots.retain(|s| s.slot_id != target_slot_id);
|
||||
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 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 {
|
||||
slot_id: 1,
|
||||
version: FORMAT_VERSION,
|
||||
kdf_salt: dummy_salt,
|
||||
kdf_params: KdfParams::default(),
|
||||
wrapped_dek: dummy_dek,
|
||||
header_nonce: dummy_nonce,
|
||||
header_tag: dummy_tag,
|
||||
};
|
||||
existing_slots.push(dummy_slot);
|
||||
}
|
||||
|
||||
let slot0_meta = existing_slots
|
||||
.iter()
|
||||
.find(|s| s.slot_id == 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Slot 0 fehlt im Header"))?;
|
||||
|
||||
let meta = ContainerMeta {
|
||||
version: FORMAT_VERSION,
|
||||
kdf_salt: slot0_meta.kdf_salt,
|
||||
kdf_params: slot0_meta.kdf_params.clone(),
|
||||
wrapped_dek: slot0_meta.wrapped_dek.clone(),
|
||||
header_nonce: slot0_meta.header_nonce,
|
||||
header_tag: slot0_meta.header_tag,
|
||||
slots: existing_slots,
|
||||
};
|
||||
|
||||
db.restore_meta(&meta)
|
||||
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
||||
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rekonstruiert den Decoy-Slot (Slot 0) mithilfe des 24-Wort BIP-39 Notfallschlüssels.
|
||||
pub fn restore_header_from_recovery_key(
|
||||
container_path: &Path,
|
||||
recovery_key: &str,
|
||||
new_password: &str,
|
||||
) -> Result<()> {
|
||||
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::*;
|
||||
use crate::crypto::{generate_dek, generate_salt, unwrap_dek, wrap_dek};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
if backup_path.exists() {
|
||||
let _ = fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
let password = "SuperSecretPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::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(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 1. Export
|
||||
export_header_backup(&container_path, &backup_path).expect("Export header");
|
||||
assert!(backup_path.exists());
|
||||
|
||||
// 2. Header in DB gezielt zerstören/löschen
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
drop(conn);
|
||||
|
||||
// Prüfen, dass Container jetzt unlesbar ist
|
||||
let broken_db = Database::open(&container_path).unwrap();
|
||||
assert!(broken_db.read_meta().is_err());
|
||||
drop(broken_db);
|
||||
|
||||
// 3. Restore
|
||||
restore_header_backup(&container_path, &backup_path).expect("Restore header");
|
||||
|
||||
// 4. Verifikation: Container wieder voll entschlüsselbar
|
||||
let restored_db = Database::open(&container_path).unwrap();
|
||||
let meta = restored_db.read_meta().expect("Read restored meta");
|
||||
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||
let active_dek = unwrap_dek(
|
||||
&restored_kek,
|
||||
&meta.wrapped_dek,
|
||||
&meta.header_nonce,
|
||||
&meta.header_tag,
|
||||
)
|
||||
.expect("Unwrap restored DEK");
|
||||
assert_eq!(*dek, *active_dek);
|
||||
|
||||
let _ = fs::remove_file(&container_path);
|
||||
let _ = fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
#[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()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let old_password = "ForgottenOldPassword!";
|
||||
let new_password = "BrandNewRescuedPassword2026!";
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
// 24-Wort Notfallschlüssel sichern
|
||||
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.checkpoint().unwrap();
|
||||
|
||||
// Header zerstören
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
drop(conn);
|
||||
|
||||
// Mit 24-Wort Schlüssel und NEUEM Passwort wiederherstellen
|
||||
restore_header_from_recovery_key(&container_path, &phrase, new_password)
|
||||
.expect("Restore from recovery key");
|
||||
|
||||
// Verifikation: Neues Passwort entschlüsselt originalen DEK
|
||||
let rescued_db = Database::open(&container_path).unwrap();
|
||||
let meta = rescued_db.read_meta().expect("Read rescued meta");
|
||||
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||
let unwrapped = unwrap_dek(
|
||||
&new_kek,
|
||||
&meta.wrapped_dek,
|
||||
&meta.header_nonce,
|
||||
&meta.header_tag,
|
||||
)
|
||||
.expect("Unwrap rescued DEK");
|
||||
assert_eq!(*dek, *unwrapped);
|
||||
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
}
|
||||
+3001
-155
File diff suppressed because it is too large
Load Diff
+1517
File diff suppressed because it is too large
Load Diff
@@ -92,8 +92,158 @@ pub fn red(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn magenta(s: &str) -> String {
|
||||
if is_vt_enabled() {
|
||||
format!("\x1b[1;35m{s}\x1b[0m")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatiert eine Byte-Anzahl lesbar in B, KB, MB, GB oder TB.
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
const TB: f64 = GB * 1024.0;
|
||||
|
||||
let b = bytes as f64;
|
||||
if b >= TB {
|
||||
format!("{:.2} TB", b / TB)
|
||||
} else if b >= GB {
|
||||
format!("{:.2} GB", b / GB)
|
||||
} else if b >= MB {
|
||||
format!("{:.2} MB", b / MB)
|
||||
} else if b >= KB {
|
||||
format!("{:.2} KB", b / KB)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...`
|
||||
pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
||||
let tag = cyan(&format!("[{}/{}]", num, total));
|
||||
println!(" {} {} {}", tag, icon, msg);
|
||||
}
|
||||
|
||||
/// Gibt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel in einer hervorgehobenen Sicherheitsbox aus.
|
||||
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!("│ 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("├─────────────────────────────────────────────────────────────┤")
|
||||
);
|
||||
|
||||
for row in 0..8 {
|
||||
let w1 = if row < words.len() {
|
||||
format!("{:2}. {:<11}", row + 1, words[row])
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let w2 = if row + 8 < words.len() {
|
||||
format!("{:2}. {:<11}", row + 9, words[row + 8])
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let w3 = if row + 16 < words.len() {
|
||||
format!("{:2}. {:<11}", row + 17, words[row + 16])
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
println!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3));
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
yellow("└─────────────────────────────────────────────────────────────┘")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Gibt den detaillierten Bericht einer Container-Integritätsprüfung aus.
|
||||
pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum Container-Integritätsprüfung (FSCK) │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", report.container_path);
|
||||
println!(" Format: Version {}", report.format_version);
|
||||
println!();
|
||||
|
||||
let sqlite_status = if report.sqlite_ok {
|
||||
green("✔ OK")
|
||||
} else {
|
||||
red("✖ FEHLER")
|
||||
};
|
||||
let header_status = if report.header_ok {
|
||||
green("✔ OK")
|
||||
} else {
|
||||
red("✖ BESCHÄDIGT")
|
||||
};
|
||||
let tree_status = if report.orphan_nodes == 0 {
|
||||
green("✔ KONSISTENT")
|
||||
} else {
|
||||
red("✖ INKONSISTENT")
|
||||
};
|
||||
let chunk_status = if report.corrupted_chunks == 0 {
|
||||
green("✔ AUTHENTIFIZIERT")
|
||||
} else {
|
||||
red("✖ BESCHÄDIGT")
|
||||
};
|
||||
|
||||
println!(" • SQLite B-Tree Integrität: {}", sqlite_status);
|
||||
println!(" • Header & KDF-Metadaten: {}", header_status);
|
||||
println!(" • Verzeichnisbaum & Inodes: {}", tree_status);
|
||||
println!(" • AEAD Chunk-Authentizität: {}", chunk_status);
|
||||
println!();
|
||||
println!(" Statistiken:");
|
||||
println!(" - Ordner: {}", report.total_dirs);
|
||||
println!(" - Dateien: {}", report.total_files);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
if !report.errors.is_empty() {
|
||||
println!();
|
||||
println!("{}", red(" Gefundene Probleme / Fehler:"));
|
||||
for err in &report.errors {
|
||||
println!(" {} {}", red("✖"), err);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
if report.is_healthy() {
|
||||
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!();
|
||||
}
|
||||
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ui;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum";
|
||||
|
||||
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)]
|
||||
pub struct ReleaseAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Repräsentiert ein Release von der Gitea Releases-API
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct GiteaRelease {
|
||||
pub id: u64,
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub html_url: String,
|
||||
pub published_at: Option<String>,
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
/// Optionen für das Upgrade
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpgradeOptions {
|
||||
pub check_only: bool,
|
||||
pub yes: bool,
|
||||
pub force: bool,
|
||||
pub base_url: String,
|
||||
pub insecure_url: bool,
|
||||
}
|
||||
|
||||
impl Default for UpgradeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_only: false,
|
||||
yes: false,
|
||||
force: false,
|
||||
base_url: OFFICIAL_UPGRADE_URL.to_string(),
|
||||
insecure_url: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ü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, 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 {
|
||||
Scoop,
|
||||
Winget,
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "Scoop",
|
||||
PackageManager::Winget => "Winget",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade_command(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "scoop update sanctum",
|
||||
PackageManager::Winget => "winget upgrade HaraldPansi.Sanctum",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft, ob die laufende Binary durch einen Paketmanager (Scoop oder Winget) verwaltet wird.
|
||||
pub fn detect_package_manager() -> Option<PackageManager> {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
detect_package_manager_from_path(&exe_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion für die Paketmanager-Erkennung anhand eines Pfads (für Tests isolierbar).
|
||||
pub fn detect_package_manager_from_path(path: &Path) -> Option<PackageManager> {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
if s.contains("scoop\\apps\\sanctum")
|
||||
|| s.contains("scoop/apps/sanctum")
|
||||
|| s.contains("scoop\\shims")
|
||||
|| s.contains("scoop/shims")
|
||||
{
|
||||
return Some(PackageManager::Scoop);
|
||||
}
|
||||
if s.contains("winget\\packages")
|
||||
|| s.contains("winget/packages")
|
||||
|| s.contains("winget\\links")
|
||||
|| s.contains("winget/links")
|
||||
{
|
||||
return Some(PackageManager::Winget);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ruft das neueste Release von der Gitea Releases-API ab.
|
||||
pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
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("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Netzwerkfehler beim Abrufen der Releases von '{}'", api_url))?;
|
||||
|
||||
let release: GiteaRelease = resp
|
||||
.into_json()
|
||||
.context("Ungültiges JSON-Format von der Gitea Releases-API empfangen")?;
|
||||
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// 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")),
|
||||
)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
|
||||
|
||||
let text = resp
|
||||
.into_string()
|
||||
.with_context(|| format!("Fehler beim Lesen des Texts von '{}'", url))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Extrahiert die SHA-256 Prüfsumme für ein bestimmtes Asset aus dem Inhalt von `SHA256SUMS.txt`.
|
||||
pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) -> Option<String> {
|
||||
for line in sha256sums_content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let hash = parts[0];
|
||||
let filename = parts[1].trim_start_matches('*');
|
||||
if filename.eq_ignore_ascii_case(target_asset) && hash.len() == 64 {
|
||||
return Some(hash.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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(['v', 'V']);
|
||||
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
|
||||
}
|
||||
|
||||
/// Vergleicht die aktuelle Version mit der entfernten Version.
|
||||
/// Gibt `true` zurück, wenn `remote > current`.
|
||||
pub fn is_newer_version(current: &str, remote: &str) -> bool {
|
||||
let cur = match parse_clean_version(current) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let rem = match parse_clean_version(remote) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rem > cur
|
||||
}
|
||||
|
||||
/// Lädt das Ziel-Binary in eine temporäre Datei herunter und validiert dabei den SHA-256 Hash.
|
||||
pub fn download_and_verify_binary(
|
||||
download_url: &str,
|
||||
expected_hash: &str,
|
||||
expected_size: u64,
|
||||
) -> Result<PathBuf> {
|
||||
let resp = ureq::get(download_url)
|
||||
.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))?;
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
|
||||
let mut temp_file = tempfile::Builder::new()
|
||||
.prefix("sanctum_update_")
|
||||
.suffix(if cfg!(windows) { ".exe" } else { "" })
|
||||
.tempfile()
|
||||
.context("Konnte temporäre Update-Datei nicht sicher erstellen")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
|
||||
let mut downloaded_bytes = 0u64;
|
||||
|
||||
println!(" • Herunterladen von: {}", download_url);
|
||||
print!(" • Fortschritt: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.context("Fehler beim Lesen des Download-Datenstroms")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
temp_file
|
||||
.as_file_mut()
|
||||
.write_all(&buffer[..n])
|
||||
.context("Fehler beim Schreiben in die temporäre Datei")?;
|
||||
hasher.update(&buffer[..n]);
|
||||
|
||||
downloaded_bytes += n as u64;
|
||||
|
||||
if expected_size > 0 {
|
||||
let percent = (downloaded_bytes as f64 / expected_size as f64 * 100.0).min(100.0);
|
||||
print!(
|
||||
"\r • Fortschritt: {} / {} ({:.1}%)",
|
||||
ui::format_bytes(downloaded_bytes),
|
||||
ui::format_bytes(expected_size),
|
||||
percent
|
||||
);
|
||||
} else {
|
||||
print!(
|
||||
"\r • Fortschritt: {}",
|
||||
ui::format_bytes(downloaded_bytes)
|
||||
);
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
println!();
|
||||
|
||||
temp_file
|
||||
.as_file_mut()
|
||||
.flush()
|
||||
.context("Fehler beim Flush der temporären Datei")?;
|
||||
|
||||
// SHA-256 Integritätsprüfung
|
||||
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
|
||||
let expected_lower = expected_hash.to_lowercase();
|
||||
|
||||
if calculated_hash != expected_lower {
|
||||
// temp_file wird beim Drop automatisch gelöscht
|
||||
bail!(
|
||||
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
|
||||
expected_lower,
|
||||
calculated_hash
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
" • SHA-256: {} (verifiziert)",
|
||||
ui::green(&calculated_hash[..16])
|
||||
);
|
||||
|
||||
let (_, path) = temp_file
|
||||
.keep()
|
||||
.context("Konnte temporäre Datei nicht für Ersetzung persistieren")?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Führt den In-Place-Austausch des laufenden Binaries durch.
|
||||
pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Konnte den Pfad der aktuell ausgeführten Datei nicht ermitteln")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(temp_binary) {
|
||||
let mut perms = meta.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let _ = fs::set_permissions(temp_binary, perms);
|
||||
}
|
||||
}
|
||||
|
||||
let replace_res = self_replace::self_replace(temp_binary);
|
||||
let _ = fs::remove_file(temp_binary);
|
||||
|
||||
replace_res.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
|
||||
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Führt den gesamten Upgrade-Workflow aus.
|
||||
pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
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('/');
|
||||
if !is_official_server {
|
||||
if !options.insecure_url {
|
||||
bail!(
|
||||
"Sicherheitsverstoß: Abweichende Server-URL '{}' ist nicht vertrauenswürdig.\n\
|
||||
Aus Sicherheitsgründen sind Drittanbieter-URLs standardmäßig gesperrt.\n\
|
||||
Falls beabsichtigt, verwenden Sie das Flag '--insecure-url'.",
|
||||
options.base_url
|
||||
);
|
||||
}
|
||||
|
||||
// Auch mit --insecure-url ist zwingend eine manuelle Bestätigung erforderlich (nicht mit -y überschreibbar!)
|
||||
println!(
|
||||
" {} WARNUNG: Sie aktualisieren von einem inoffiziellen Server: {}",
|
||||
ui::red("[SICHERHEITSWARNUNG]"),
|
||||
options.base_url
|
||||
);
|
||||
println!(" Offizieller Server ist: {}", OFFICIAL_UPGRADE_URL);
|
||||
print!(" Möchten Sie dieses ungesicherte Update WIRKLICH fortsetzen? [Tippen Sie 'JA' zur Bestätigung]: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut confirm = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut confirm)
|
||||
.context("Fehler beim Einlesen der Bestätigung")?;
|
||||
if confirm.trim() != "JA" {
|
||||
bail!("Upgrade von Drittanbieter-URL durch Benutzer abgebrochen.");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// 1. Paketmanager-Prüfung
|
||||
if let Some(pm) = detect_package_manager() {
|
||||
println!(
|
||||
" {} Sanctum wird anscheinend über {} verwaltet!",
|
||||
ui::yellow("[!]"),
|
||||
pm.name()
|
||||
);
|
||||
println!(
|
||||
" Empfohlener Aktualisierungsbefehl: {}",
|
||||
ui::cyan(pm.upgrade_command())
|
||||
);
|
||||
println!();
|
||||
|
||||
if !options.force && !options.yes && !options.check_only {
|
||||
print!(" Möchten Sie das direkte In-Place-Upgrade trotzdem fortsetzen? [j/N]: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed != "j" && trimmed != "ja" && trimmed != "y" && trimmed != "yes" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Neuestes Release abfragen
|
||||
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!(" • Release-Name: {}", release.name);
|
||||
if let Some(ref pub_at) = release.published_at {
|
||||
println!(" • Veröffentlicht: {}", pub_at);
|
||||
}
|
||||
println!(" • Release-URL: {}", release.html_url);
|
||||
println!();
|
||||
|
||||
if !is_newer && !options.force {
|
||||
println!(
|
||||
" {} Sie verwenden bereits die neueste Version von Sanctum.",
|
||||
ui::green("✔")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if options.check_only {
|
||||
if is_newer {
|
||||
println!(
|
||||
" {} Ein Update auf {} ist verfügbar! Führen Sie 'sanctum upgrade' aus.",
|
||||
ui::yellow("[!]"),
|
||||
remote_tag
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Ziel-Asset, Checksumme und Minisign-Signatur identifizieren
|
||||
let target_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(TARGET_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde kein passendes Binary für Ihr Betriebssystem ('{}') gefunden!",
|
||||
remote_tag,
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde keine Prüfsummendatei ('{}') gefunden!",
|
||||
remote_tag,
|
||||
CHECKSUM_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
let sig_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_SIG_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde keine Minisign-Signatur ('{}') gefunden! Unsignierte Updates werden verweigert.",
|
||||
remote_tag,
|
||||
CHECKSUM_SIG_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
|
||||
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
|
||||
);
|
||||
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
|
||||
|
||||
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.")?;
|
||||
validate_trusted_comment_version(&trusted_comment, remote_tag)?;
|
||||
println!(
|
||||
" • Signatur: {} (Minisign Ed25519, Version validiert)",
|
||||
ui::green("Gültig")
|
||||
);
|
||||
|
||||
let expected_hash =
|
||||
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
||||
if !options.yes {
|
||||
println!(
|
||||
" • Ziel-Binary: {} ({})",
|
||||
target_asset.name,
|
||||
ui::format_bytes(target_asset.size)
|
||||
);
|
||||
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
|
||||
println!();
|
||||
print!(
|
||||
" Möchten Sie Sanctum jetzt von v{} auf {} aktualisieren? [J/n]: ",
|
||||
current_version, remote_tag
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed == "n" || trimmed == "nein" || trimmed == "no" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// 6. Download & Verifikation
|
||||
println!(" {} Lade neues Binary herunter ...", ui::dim("[-]"));
|
||||
let temp_file = download_and_verify_binary(
|
||||
&target_asset.browser_download_url,
|
||||
&expected_hash,
|
||||
target_asset.size,
|
||||
)?;
|
||||
|
||||
// 7. In-Place-Austausch
|
||||
println!(" {} Führe In-Place-Austausch durch ...", ui::dim("[-]"));
|
||||
let updated_exe = apply_upgrade(&temp_file)?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!(
|
||||
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
|
||||
remote_tag
|
||||
);
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Pfad: {}", updated_exe.display());
|
||||
println!(" • Neue Version: {}", ui::green(remote_tag));
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerificationReport {
|
||||
pub container_path: String,
|
||||
pub format_version: u32,
|
||||
pub sqlite_ok: bool,
|
||||
pub sqlite_errors: Vec<String>,
|
||||
pub header_ok: bool,
|
||||
pub header_error: Option<String>,
|
||||
pub total_nodes: usize,
|
||||
pub total_dirs: usize,
|
||||
pub total_files: usize,
|
||||
pub total_chunks: usize,
|
||||
pub total_bytes_decrypted: u64,
|
||||
pub corrupted_chunks: usize,
|
||||
pub orphan_nodes: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl VerificationReport {
|
||||
pub fn is_healthy(&self) -> bool {
|
||||
self.sqlite_ok
|
||||
&& self.header_ok
|
||||
&& self.corrupted_chunks == 0
|
||||
&& self.orphan_nodes == 0
|
||||
&& self.errors.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Führt eine detaillierte Integritäts- und Bitrot-Prüfung auf einem Sanctum-Container durch.
|
||||
pub fn verify_container(
|
||||
container_path: &Path,
|
||||
dek: Option<&Zeroizing<[u8; 32]>>,
|
||||
full_chunks: bool,
|
||||
) -> Result<VerificationReport> {
|
||||
if !container_path.exists() {
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let mut report = VerificationReport {
|
||||
container_path: container_path.display().to_string(),
|
||||
format_version: 0,
|
||||
sqlite_ok: true,
|
||||
sqlite_errors: Vec::new(),
|
||||
header_ok: true,
|
||||
header_error: None,
|
||||
total_nodes: 0,
|
||||
total_dirs: 0,
|
||||
total_files: 0,
|
||||
total_chunks: 0,
|
||||
total_bytes_decrypted: 0,
|
||||
corrupted_chunks: 0,
|
||||
orphan_nodes: 0,
|
||||
errors: Vec::new(),
|
||||
};
|
||||
|
||||
// 1. SQLite B-Tree & Foreign Key Prüfung
|
||||
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;
|
||||
report.sqlite_errors = sqlite_issues;
|
||||
}
|
||||
|
||||
// 2. Header & Magic Bytes Prüfung
|
||||
let meta = match db.read_meta() {
|
||||
Ok(m) => {
|
||||
report.format_version = m.version;
|
||||
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));
|
||||
}
|
||||
if m.kdf_salt.len() != 16 {
|
||||
report.header_ok = false;
|
||||
report.header_error = Some("Ungültige KDF-Salt-Länge".to_string());
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
Err(e) => {
|
||||
report.header_ok = false;
|
||||
report.header_error = Some(format!("{e}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Node-Hierarchie & Strukturprüfung
|
||||
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")?;
|
||||
report.total_nodes = all_nodes.len();
|
||||
|
||||
let mut node_map = HashMap::new();
|
||||
for node in &all_nodes {
|
||||
node_map.insert(node.id, node.clone());
|
||||
}
|
||||
|
||||
// Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault)
|
||||
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());
|
||||
}
|
||||
if root.parent_id.is_some() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if root2.parent_id.is_some() {
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
|
||||
for node in &all_nodes {
|
||||
if node.id == 1 || node.id == 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match node.parent_id {
|
||||
Some(pid) => match node_map.get(&pid) {
|
||||
Some(parent) => {
|
||||
if !parent.is_dir {
|
||||
report.errors.push(format!(
|
||||
"Knoten '{}' (id={}) hat einen Parent (id={}), der kein Verzeichnis ist!",
|
||||
node.name, node.id, pid
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
report.orphan_nodes += 1;
|
||||
report.errors.push(format!(
|
||||
"Verwaister Knoten: '{}' (id={}) verweist auf nicht-existenten Parent id={}",
|
||||
node.name, node.id, pid
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
report.orphan_nodes += 1;
|
||||
report.errors.push(format!(
|
||||
"Verwaister Knoten ohne Parent: '{}' (id={})",
|
||||
node.name, node.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Zyklenprüfung
|
||||
let mut visited = HashSet::new();
|
||||
visited.insert(node.id);
|
||||
let mut curr_parent = node.parent_id;
|
||||
while let Some(pid) = curr_parent {
|
||||
if !visited.insert(pid) {
|
||||
report.errors.push(format!(
|
||||
"Zyklische Verzeichnisreferenz bei Knoten '{}' (id={}) entdeckt!",
|
||||
node.name, node.id
|
||||
));
|
||||
break;
|
||||
}
|
||||
curr_parent = node_map.get(&pid).and_then(|n| n.parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1)
|
||||
let mut vault0_nodes = HashSet::new();
|
||||
let mut vault1_nodes = HashSet::new();
|
||||
vault0_nodes.insert(1i64);
|
||||
if node_map.contains_key(&2) {
|
||||
vault1_nodes.insert(2i64);
|
||||
}
|
||||
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for node in &all_nodes {
|
||||
if let Some(pid) = node.parent_id {
|
||||
if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) {
|
||||
vault0_nodes.insert(node.id);
|
||||
changed = true;
|
||||
} else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) {
|
||||
vault1_nodes.insert(node.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört
|
||||
let active_vault_nodes = if let Some(active_dek) = dek {
|
||||
let is_vault1 = {
|
||||
let mut found_v1 = false;
|
||||
for node in &all_nodes {
|
||||
if node.parent_id == Some(2) {
|
||||
if crate::crypto::decrypt_node_name(active_dek, 2, &node.name).is_some() {
|
||||
found_v1 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found_v1
|
||||
};
|
||||
|
||||
if is_vault1 {
|
||||
Some(&vault1_nodes)
|
||||
} else {
|
||||
Some(&vault0_nodes)
|
||||
}
|
||||
} else {
|
||||
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()
|
||||
.context("Fehler beim Abrufen der Chunk-Liste")?;
|
||||
|
||||
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) {
|
||||
report.errors.push(format!(
|
||||
"Verwaister Daten-Chunk: Node #{node_id} Chunk #{chunk_index} gehört zu keinem bekannten Inode!"
|
||||
));
|
||||
}
|
||||
|
||||
if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
|
||||
// Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault)
|
||||
if target_nodes.contains(&node_id) && full_chunks {
|
||||
match db.read_chunk(node_id, chunk_index) {
|
||||
Ok(Some(record)) => {
|
||||
match decrypt_chunk(
|
||||
active_dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&record.ciphertext,
|
||||
&record.nonce,
|
||||
&record.tag,
|
||||
format_version,
|
||||
record.generation,
|
||||
) {
|
||||
Ok(plaintext) => {
|
||||
report.total_bytes_decrypted += plaintext.len() as u64;
|
||||
}
|
||||
Err(e) => {
|
||||
report.corrupted_chunks += 1;
|
||||
report.errors.push(format!(
|
||||
"AEAD/Integritätsfehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
report.errors.push(format!(
|
||||
"Chunk #{chunk_index} für Node #{node_id} in Index gefunden, aber Daten nicht lesbar!"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
report.corrupted_chunks += 1;
|
||||
report.errors.push(format!(
|
||||
"DB-Lesefehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{
|
||||
derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_verify_healthy_container() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf =
|
||||
temp_dir.join(format!("test_verify_ok_{}.sanctum", std::process::id()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let password = "HealthyTestPassword123!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::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(&container_path).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();
|
||||
let file = db.create_node(folder.id, "img.jpg", false).unwrap();
|
||||
|
||||
// 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, 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, 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.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_eq!(report.total_files, 1);
|
||||
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);
|
||||
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_detects_bitrot() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf =
|
||||
temp_dir.join(format!("test_verify_bitrot_{}.sanctum", std::process::id()));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let password = "BitrotTestPassword123!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: crate::crypto::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(&container_path).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, 0).unwrap();
|
||||
db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Bitrot simulieren: Wir flippen 1 Byte im Ciphertext in SQLite direkt
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
let mut corrupted_ct = ct.clone();
|
||||
corrupted_ct[4] ^= 0xFF; // Bit-Flip!
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||
rusqlite::params![corrupted_ct, file.id],
|
||||
)
|
||||
.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")));
|
||||
|
||||
let _ = fs::remove_file(&container_path);
|
||||
}
|
||||
}
|
||||
+1365
-149
File diff suppressed because it is too large
Load Diff
+935
@@ -0,0 +1,935 @@
|
||||
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> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn GetLogicalDrives() -> u32;
|
||||
}
|
||||
|
||||
let mask = unsafe { GetLogicalDrives() };
|
||||
if mask == 0 {
|
||||
bail!("Fehler beim Abfragen der logischen Laufwerke via Win32 API");
|
||||
}
|
||||
|
||||
// Suche von 'Z' abwärts bis 'D' (A, B für Diskettenlaufwerke, C für System reservieren)
|
||||
for ch in ('D'..='Z').rev() {
|
||||
let bit_index = (ch as u8) - b'A';
|
||||
if (mask & (1 << bit_index)) == 0 {
|
||||
return Ok(ch);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Kein freier Windows-Laufwerksbuchstabe (D: bis Z:) verfügbar!");
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok('S')
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet das eingebundene Netzlaufwerk oder Verzeichnis direkt im systemeigenen Dateimanager
|
||||
/// (Windows: Explorer, macOS: open, Linux: xdg-open).
|
||||
pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
|
||||
Command::new("explorer.exe")
|
||||
.arg(&drive_path)
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = drive_char;
|
||||
let _ = Command::new("open").arg(".").spawn();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let _ = drive_char;
|
||||
let _ = Command::new("xdg-open").arg(".").spawn();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = drive_char;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet einen beliebigen Pfad im nativen Dateimanager der Plattform.
|
||||
pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Command::new("explorer.exe")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.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())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Konnte Dateimanager via xdg-open für '{}' nicht öffnen",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED).
|
||||
pub fn notify_shell_associations_changed() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn SHChangeNotify(
|
||||
w_event_id: i32,
|
||||
u_flags: u32,
|
||||
dw_item1: *const std::ffi::c_void,
|
||||
dw_item2: *const std::ffi::c_void,
|
||||
);
|
||||
}
|
||||
|
||||
const SHCNE_ASSOCCHANGED: i32 = 0x0800_0000;
|
||||
const SHCNF_IDLIST: u32 = 0x0000;
|
||||
|
||||
unsafe {
|
||||
SHChangeNotify(
|
||||
SHCNE_ASSOCCHANGED,
|
||||
SHCNF_IDLIST,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
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.
|
||||
pub fn register_explorer_integration() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
|
||||
let exe_str = current_exe.display().to_string();
|
||||
|
||||
let reg_commands = [
|
||||
// 1. .sanctum Erweiterung mit ProgID verknüpfen
|
||||
(r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"),
|
||||
// 2. ProgID Metadaten & Beschreibung
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container",
|
||||
"",
|
||||
"Sanctum Verschlüsselter Container",
|
||||
),
|
||||
// 3. Icon
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\DefaultIcon",
|
||||
"",
|
||||
&format!("\"{exe_str}\",0"),
|
||||
),
|
||||
// 4. Standard-Doppelklick-Aktion: Mount
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\open",
|
||||
"",
|
||||
"In Sanctum öffnen",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\open\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" mount \"%1\""),
|
||||
),
|
||||
// 5. Kontextmenü-Aktion: Verify / FSCK
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify",
|
||||
"",
|
||||
"Integrität prüfen (FSCK)",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command",
|
||||
"",
|
||||
&format!("cmd /k \"\"{exe_str}\" verify \"%1\"\""),
|
||||
),
|
||||
// 6. Kontextmenü-Aktion: Header-Backup
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup",
|
||||
"",
|
||||
"Header sichern (Disaster Recovery)",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup\command",
|
||||
"",
|
||||
&format!("cmd /k \"\"{exe_str}\" backup-header \"%1\"\""),
|
||||
),
|
||||
];
|
||||
|
||||
for (key, val_name, val_data) in reg_commands {
|
||||
let mut cmd = Command::new("reg");
|
||||
cmd.arg("add").arg(key);
|
||||
if val_name.is_empty() {
|
||||
cmd.arg("/ve");
|
||||
} else {
|
||||
cmd.arg("/v").arg(val_name);
|
||||
}
|
||||
cmd.arg("/d").arg(val_data).arg("/f");
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
notify_shell_associations_changed();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = std::path::PathBuf::from(home);
|
||||
let apps_dir = home_path.join(".local/share/applications");
|
||||
let mime_dir = home_path.join(".local/share/mime/packages");
|
||||
let _ = std::fs::create_dir_all(&apps_dir);
|
||||
let _ = std::fs::create_dir_all(&mime_dir);
|
||||
|
||||
let desktop_content = "[Desktop Entry]\nType=Application\nName=Sanctum\nComment=Verschlüsselter Ein-Datei-Container\nExec=sanctum mount %f\nIcon=security-high\nTerminal=true\nMimeType=application/x-sanctum;\nCategories=Utility;Security;\n";
|
||||
let _ = std::fs::write(apps_dir.join("sanctum.desktop"), desktop_content);
|
||||
|
||||
let mime_content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n <mime-type type=\"application/x-sanctum\">\n <comment>Sanctum Verschlüsselter Container</comment>\n <glob pattern=\"*.sanctum\"/>\n </mime-type>\n</mime-info>\n";
|
||||
let _ = std::fs::write(mime_dir.join("application-x-sanctum.xml"), mime_content);
|
||||
|
||||
notify_shell_associations_changed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU) bzw. unter Linux aus Freedesktop.
|
||||
pub fn unregister_explorer_integration() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let keys_to_delete = [
|
||||
r"HKCU\Software\Classes\.sanctum",
|
||||
r"HKCU\Software\Classes\Sanctum.Container",
|
||||
];
|
||||
|
||||
for key in keys_to_delete {
|
||||
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
|
||||
}
|
||||
|
||||
notify_shell_associations_changed();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
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"),
|
||||
);
|
||||
notify_shell_associations_changed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray.
|
||||
#[cfg(windows)]
|
||||
pub fn get_default_system_icon() -> Option<tray_item::IconSource> {
|
||||
extern "system" {
|
||||
fn LoadIconW(instance: isize, icon_name: *const u16) -> isize;
|
||||
fn GetModuleHandleW(module_name: *const u16) -> isize;
|
||||
}
|
||||
|
||||
// 1. Eingebettetes Anwendungs-Icon (Ressource ID 1) aus eigenem Modul laden
|
||||
let h_instance = unsafe { GetModuleHandleW(std::ptr::null()) };
|
||||
let app_icon = unsafe { LoadIconW(h_instance, 1 as *const u16) };
|
||||
if app_icon != 0 {
|
||||
return Some(tray_item::IconSource::RawIcon(app_icon));
|
||||
}
|
||||
|
||||
// 2. Fallback: Windows IDI_SHIELD = 32518, IDI_APPLICATION = 32512
|
||||
let icon = unsafe { LoadIconW(0, 32518 as *const u16) };
|
||||
if icon != 0 {
|
||||
Some(tray_item::IconSource::RawIcon(icon))
|
||||
} else {
|
||||
let icon_app = unsafe { LoadIconW(0, 32512 as *const u16) };
|
||||
if icon_app != 0 {
|
||||
Some(tray_item::IconSource::RawIcon(icon_app))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre.
|
||||
/// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet.
|
||||
#[cfg(windows)]
|
||||
pub struct SessionLockGuard {
|
||||
hwnd: isize,
|
||||
join_handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for SessionLockGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.hwnd != 0 {
|
||||
unsafe {
|
||||
PostMessageW(self.hwnd, 0x0010 /* WM_CLOSE */, 0, 0);
|
||||
}
|
||||
}
|
||||
if let Ok(mut guard) = SESSION_LOCK_TX.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Some(handle) = self.join_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub struct SessionLockGuard;
|
||||
|
||||
#[cfg(windows)]
|
||||
static SESSION_LOCK_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(non_snake_case)]
|
||||
#[repr(C)]
|
||||
struct WNDCLASSEXW {
|
||||
cbSize: u32,
|
||||
style: u32,
|
||||
lpfnWndProc: Option<unsafe extern "system" fn(isize, u32, usize, isize) -> isize>,
|
||||
cbClsExtra: i32,
|
||||
cbWndExtra: i32,
|
||||
hInstance: isize,
|
||||
hIcon: isize,
|
||||
hCursor: isize,
|
||||
hbrBackground: isize,
|
||||
lpszMenuName: *const u16,
|
||||
lpszClassName: *const u16,
|
||||
hIconSm: isize,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(non_snake_case)]
|
||||
#[repr(C)]
|
||||
struct MSG {
|
||||
hwnd: isize,
|
||||
message: u32,
|
||||
wParam: usize,
|
||||
lParam: isize,
|
||||
time: u32,
|
||||
pt_x: i32,
|
||||
pt_y: i32,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
extern "system" {
|
||||
fn GetModuleHandleW(lpModuleName: *const u16) -> isize;
|
||||
fn RegisterClassExW(lpwcx: *const WNDCLASSEXW) -> u16;
|
||||
fn UnregisterClassW(lpClassName: *const u16, hInstance: isize) -> i32;
|
||||
fn CreateWindowExW(
|
||||
dwExStyle: u32,
|
||||
lpClassName: *const u16,
|
||||
lpWindowName: *const u16,
|
||||
dwStyle: u32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
nWidth: i32,
|
||||
nHeight: i32,
|
||||
hWndParent: isize,
|
||||
hMenu: isize,
|
||||
hInstance: isize,
|
||||
lpParam: *mut std::ffi::c_void,
|
||||
) -> isize;
|
||||
fn DestroyWindow(hwnd: isize) -> i32;
|
||||
fn DefWindowProcW(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
|
||||
fn GetMessageW(lpMsg: *mut MSG, hWnd: isize, wMsgFilterMin: u32, wMsgFilterMax: u32) -> i32;
|
||||
fn TranslateMessage(lpMsg: *const MSG) -> i32;
|
||||
fn DispatchMessageW(lpMsg: *const MSG) -> isize;
|
||||
fn PostMessageW(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> i32;
|
||||
fn PostQuitMessage(nExitCode: i32);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[link(name = "wtsapi32")]
|
||||
extern "system" {
|
||||
fn WTSRegisterSessionNotification(hwnd: isize, flags: u32) -> i32;
|
||||
fn WTSUnRegisterSessionNotification(hwnd: isize) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe extern "system" fn session_wnd_proc(
|
||||
hwnd: isize,
|
||||
msg: u32,
|
||||
wparam: usize,
|
||||
lparam: isize,
|
||||
) -> isize {
|
||||
const WM_CLOSE: u32 = 0x0010;
|
||||
const WM_WTSSESSION_CHANGE: u32 = 0x02B1;
|
||||
const WTS_SESSION_LOCK: usize = 0x7;
|
||||
const WTS_SESSION_LOGOFF: usize = 0x6;
|
||||
|
||||
match msg {
|
||||
WM_WTSSESSION_CHANGE => {
|
||||
if wparam == WTS_SESSION_LOCK || wparam == WTS_SESSION_LOGOFF {
|
||||
if let Ok(guard) = SESSION_LOCK_TX.lock() {
|
||||
if let Some(ref tx) = *guard {
|
||||
let _ = tx.blocking_send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
WM_CLOSE => {
|
||||
PostQuitMessage(0);
|
||||
0
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
/// Startet einen Hintergrundthread mit einem verdeckten Win32-Nachrichtenfenster (HWND_MESSAGE),
|
||||
/// das via `WTSRegisterSessionNotification` auf Sperr-Events (Win + L) lauscht und beim Eintreffen
|
||||
/// ein Signal an den übergebenen Tokio-Kanal sendet.
|
||||
#[cfg(windows)]
|
||||
pub fn start_session_lock_monitor(
|
||||
shutdown_tx: tokio::sync::mpsc::Sender<()>,
|
||||
) -> Result<SessionLockGuard> {
|
||||
if let Ok(mut guard) = SESSION_LOCK_TX.lock() {
|
||||
*guard = Some(shutdown_tx);
|
||||
}
|
||||
|
||||
let (hwnd_tx, hwnd_rx) = std::sync::mpsc::channel::<isize>();
|
||||
|
||||
let join_handle = std::thread::Builder::new()
|
||||
.name("sanctum-session-monitor".to_string())
|
||||
.spawn(move || unsafe {
|
||||
let h_instance = GetModuleHandleW(std::ptr::null());
|
||||
let class_name: Vec<u16> = "SanctumSessionMonitorClass\0".encode_utf16().collect();
|
||||
|
||||
let wcx = WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||
style: 0,
|
||||
lpfnWndProc: Some(session_wnd_proc),
|
||||
cbClsExtra: 0,
|
||||
cbWndExtra: 0,
|
||||
hInstance: h_instance,
|
||||
hIcon: 0,
|
||||
hCursor: 0,
|
||||
hbrBackground: 0,
|
||||
lpszMenuName: std::ptr::null(),
|
||||
lpszClassName: class_name.as_ptr(),
|
||||
hIconSm: 0,
|
||||
};
|
||||
|
||||
RegisterClassExW(&wcx);
|
||||
|
||||
let hwnd = CreateWindowExW(
|
||||
0,
|
||||
class_name.as_ptr(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
-3, // HWND_MESSAGE
|
||||
0,
|
||||
h_instance,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
|
||||
if hwnd == 0 {
|
||||
let _ = hwnd_tx.send(0);
|
||||
return;
|
||||
}
|
||||
|
||||
WTSRegisterSessionNotification(hwnd, 0); // NOTIFY_FOR_THIS_SESSION = 0
|
||||
|
||||
let _ = hwnd_tx.send(hwnd);
|
||||
|
||||
let mut msg = std::mem::zeroed::<MSG>();
|
||||
while GetMessageW(&mut msg, 0, 0, 0) > 0 {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
WTSUnRegisterSessionNotification(hwnd);
|
||||
DestroyWindow(hwnd);
|
||||
UnregisterClassW(class_name.as_ptr(), h_instance);
|
||||
})
|
||||
.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}")
|
||||
})?;
|
||||
|
||||
if hwnd == 0 {
|
||||
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
|
||||
}
|
||||
|
||||
Ok(SessionLockGuard {
|
||||
hwnd,
|
||||
join_handle: Some(join_handle),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn start_session_lock_monitor(
|
||||
_shutdown_tx: tokio::sync::mpsc::Sender<()>,
|
||||
) -> Result<SessionLockGuard> {
|
||||
Ok(SessionLockGuard)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Console Close Event Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
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;
|
||||
const CTRL_BREAK_EVENT: u32 = 1;
|
||||
const CTRL_CLOSE_EVENT: u32 = 2;
|
||||
const CTRL_LOGOFF_EVENT: u32 = 5;
|
||||
const CTRL_SHUTDOWN_EVENT: u32 = 6;
|
||||
|
||||
match ctrl_type {
|
||||
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
|
||||
// 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 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)
|
||||
if let Ok(guard) = CONSOLE_CTRL_TX.lock() {
|
||||
if let Some(ref tx) = *guard {
|
||||
let _ = tx.blocking_send(());
|
||||
}
|
||||
}
|
||||
1 // TRUE: Event abgearbeitet
|
||||
}
|
||||
CTRL_C_EVENT | CTRL_BREAK_EVENT => {
|
||||
// Ctrl+C wird primär von tokio::signal::ctrl_c() behandelt
|
||||
0
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII Guard zur sauberen Deregistrierung des Win32 Console-Control-Handlers.
|
||||
pub struct ConsoleCtrlGuard;
|
||||
|
||||
impl Drop for ConsoleCtrlGuard {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn SetConsoleCtrlHandler(
|
||||
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
||||
add: i32,
|
||||
) -> i32;
|
||||
}
|
||||
unsafe {
|
||||
SetConsoleCtrlHandler(Some(console_ctrl_routine), 0);
|
||||
}
|
||||
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registriert einen Win32 Console Control Handler für CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT
|
||||
/// und CTRL_SHUTDOWN_EVENT, um verwaiste Netzlaufwerke beim Schließen des Konsolenfensters zu verhindern.
|
||||
pub fn start_console_ctrl_monitor(
|
||||
shutdown_tx: tokio::sync::mpsc::Sender<()>,
|
||||
drive_letter: char,
|
||||
) -> Result<ConsoleCtrlGuard> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn SetConsoleCtrlHandler(
|
||||
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
||||
add: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = CONSOLE_CTRL_TX.lock() {
|
||||
*guard = Some(shutdown_tx);
|
||||
}
|
||||
if let Ok(mut guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||
*guard = Some(drive_letter);
|
||||
}
|
||||
|
||||
let res = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_routine), 1) };
|
||||
if res == 0 {
|
||||
bail!("Konnte Win32 SetConsoleCtrlHandler nicht registrieren");
|
||||
}
|
||||
|
||||
Ok(ConsoleCtrlGuard)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = shutdown_tx;
|
||||
let _ = drive_letter;
|
||||
Ok(ConsoleCtrlGuard)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys unter Windows bzw. Swap unter Linux/macOS).
|
||||
pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn VirtualLock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { VirtualLock(ptr as *const std::ffi::c_void, len) != 0 }
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
extern "C" {
|
||||
fn mlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { mlock(ptr as *const std::ffi::c_void, len) == 0 }
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (ptr, len);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Entriegelt einen zuvor mit `lock_memory` geschützten Speicherbereich im RAM.
|
||||
pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn VirtualUnlock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { VirtualUnlock(ptr as *const std::ffi::c_void, len) != 0 }
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
extern "C" {
|
||||
fn munlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { munlock(ptr as *const std::ffi::c_void, len) == 0 }
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (ptr, len);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
#[test]
|
||||
fn test_virtual_lock_memory_lifecycle() {
|
||||
let buffer = vec![0x42u8; 4096];
|
||||
let _ = lock_memory(buffer.as_ptr(), buffer.len());
|
||||
let _ = unlock_memory(buffer.as_ptr(), buffer.len());
|
||||
assert_eq!(buffer[0], 0x42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_next_available_drive() {
|
||||
let drive = find_next_available_drive().expect("Find next drive");
|
||||
assert!(drive.is_ascii_alphabetic());
|
||||
assert!(('D'..='Z').contains(&drive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_lock_monitor_lifecycle() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let monitor = start_session_lock_monitor(tx);
|
||||
assert!(monitor.is_ok());
|
||||
// Dropping monitor closes message loop and joins thread cleanly
|
||||
drop(monitor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_ctrl_monitor_lifecycle() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let monitor = start_console_ctrl_monitor(tx, 'Z');
|
||||
assert!(monitor.is_ok());
|
||||
drop(monitor);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 50 Millisekunden finden (typisch < 0.2 ms, O(1) statt O(N))
|
||||
assert!(
|
||||
elapsed.as_millis() < 50,
|
||||
"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());
|
||||
}
|
||||
+1661
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use dav_server::{
|
||||
davpath::DavPath,
|
||||
fs::{DavFileSystem, OpenOptions},
|
||||
};
|
||||
use rand::RngCore;
|
||||
use sanctum::{
|
||||
crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION},
|
||||
storage::Database,
|
||||
verify::verify_container,
|
||||
vfs::SanctumFs,
|
||||
};
|
||||
|
||||
/// Live-Crash- und Stresstest: Simuliert harten Verbindungsabbruch und Power-Cut
|
||||
/// während intensiver paralleler Schreibvorgänge im VFS.
|
||||
#[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()
|
||||
));
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let password = "LiveStressPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: sanctum::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("DEK wrapping");
|
||||
|
||||
// 1. Initialisierung des Containers
|
||||
{
|
||||
let db = Database::open(&container_path).expect("Open database");
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||
.expect("Init schema");
|
||||
db.checkpoint().expect("Initial Checkpoint");
|
||||
}
|
||||
|
||||
// 2. Parallele Schreiblast mit SanctumFs erzeugen
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let db = Database::open(&container_path).expect("Open database for VFS");
|
||||
let fs = SanctumFs::new(db, dek.clone(), FORMAT_VERSION);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Spawn 4 parallele Schreiber
|
||||
for worker_id in 0..4 {
|
||||
let fs_clone = fs.clone();
|
||||
let stop_clone = stop_signal.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut file_idx = 0;
|
||||
while !stop_clone.load(Ordering::Relaxed) && file_idx < 10 {
|
||||
let file_path_str = format!("/worker_{}_file_{}.dat", worker_id, file_idx);
|
||||
let dav_path = DavPath::new(&file_path_str).unwrap();
|
||||
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create = true;
|
||||
opts.truncate = true;
|
||||
|
||||
// Datei anlegen
|
||||
let mut file = match fs_clone.open(&dav_path, opts).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
// Mehrere 256-KB Blöcke schreiben (über mehrere Chunks hinweg)
|
||||
let mut payload = vec![0u8; 256 * 1024];
|
||||
rand::thread_rng().fill_bytes(&mut payload);
|
||||
|
||||
for _ in 0..6 {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let _ = file.write_bytes(Bytes::copy_from_slice(&payload)).await;
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
file_idx += 1;
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Lass die Worker 500ms unter Volllast schreiben
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// 3. Simuliere abrupten Prozessabbruch (Hard Kill / Power Cut)
|
||||
// Wir brechen die Worker hart ab (Cancel) und verwerfen das FS-Handle ohne sauberen Unmount
|
||||
stop_signal.store(true, Ordering::SeqCst);
|
||||
for h in handles {
|
||||
h.abort(); // Simuliert Kill
|
||||
}
|
||||
|
||||
// FS ohne Checkpoint/Drop-Finalisierung freigeben
|
||||
drop(fs);
|
||||
|
||||
// 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");
|
||||
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"
|
||||
);
|
||||
|
||||
// 5. Konsistentes Weiterarbeiten nach dem Absturz
|
||||
let db_recovered = Database::open(&container_path).expect("Open database after crash");
|
||||
let fs_recovered = SanctumFs::new(db_recovered, dek.clone(), FORMAT_VERSION);
|
||||
|
||||
// Neue Datei im wiederhergestellten Dateisystem anlegen und lesen
|
||||
let recovery_test_path = DavPath::new("/post_crash_verification.txt").unwrap();
|
||||
{
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create = true;
|
||||
opts.truncate = true;
|
||||
let mut file = fs_recovered
|
||||
.open(&recovery_test_path, opts)
|
||||
.await
|
||||
.expect("Create post-crash file");
|
||||
file.write_bytes(Bytes::from_static(b"Sanctum Crash Consistency Verified!"))
|
||||
.await
|
||||
.expect("Write post crash file");
|
||||
file.flush().await.expect("Flush post crash file");
|
||||
}
|
||||
|
||||
// Datei wieder einlesen
|
||||
{
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.read = true;
|
||||
let mut file = fs_recovered
|
||||
.open(&recovery_test_path, opts)
|
||||
.await
|
||||
.expect("Read post-crash file");
|
||||
let bytes = file.read_bytes(1024).await.expect("Read bytes");
|
||||
assert_eq!(&bytes[..], b"Sanctum Crash Consistency Verified!");
|
||||
}
|
||||
|
||||
// Sauberes Aufräumen der Testdatei
|
||||
drop(fs_recovered);
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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() {
|
||||
// Gültige IPv4 Loopback Varianten
|
||||
assert!(is_loopback_host("127.0.0.1"));
|
||||
assert!(is_loopback_host("127.0.0.1:80"));
|
||||
assert!(is_loopback_host("127.0.0.1:8080"));
|
||||
assert!(is_loopback_host("127.0.0.1:65535"));
|
||||
|
||||
// Gültige Hostname Loopback Varianten
|
||||
assert!(is_loopback_host("localhost"));
|
||||
assert!(is_loopback_host("localhost:80"));
|
||||
assert!(is_loopback_host("localhost:8443"));
|
||||
assert!(is_loopback_host("LOCALHOST"));
|
||||
assert!(is_loopback_host("LocalHost:9000"));
|
||||
|
||||
// Gültige IPv6 Loopback Varianten
|
||||
assert!(is_loopback_host("[::1]"));
|
||||
assert!(is_loopback_host("[::1]:80"));
|
||||
assert!(is_loopback_host("[::1]:8443"));
|
||||
assert!(is_loopback_host("::1"));
|
||||
|
||||
// DNS-Rebinding & Spoofing Angriffe (MÜSSEN abgewiesen werden)
|
||||
assert!(!is_loopback_host("127.0.0.1.attacker.com"));
|
||||
assert!(!is_loopback_host("localhost.attacker.com"));
|
||||
assert!(!is_loopback_host("localhost.evil.org:8080"));
|
||||
assert!(!is_loopback_host("notlocalhost"));
|
||||
assert!(!is_loopback_host("attacker.com"));
|
||||
assert!(!is_loopback_host("attacker.com:127001"));
|
||||
assert!(!is_loopback_host("192.168.1.1"));
|
||||
assert!(!is_loopback_host("10.0.0.1"));
|
||||
assert!(!is_loopback_host("0.0.0.0"));
|
||||
assert!(!is_loopback_host(""));
|
||||
assert!(!is_loopback_host(" "));
|
||||
assert!(!is_loopback_host("foo:bar:baz"));
|
||||
}
|
||||
|
||||
#[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()));
|
||||
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("WalCleanupPassword2026!", &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 Knoten, um Schreibaktivität im WAL zu erzeugen
|
||||
let _ = db.create_node(1, "test_file.txt", false).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Bereinigungslogik (wie in mount.rs unmount) ausführen
|
||||
let base_os = container_path.as_os_str().to_os_string();
|
||||
let mut wal = base_os.clone();
|
||||
wal.push("-wal");
|
||||
let _ = std::fs::remove_file(&wal);
|
||||
let mut shm = base_os;
|
||||
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"
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mount_rejects_tampered_metadata_mac() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf = temp_dir.join(format!(
|
||||
"test_mount_tampered_mac_{}.sanctum",
|
||||
std::process::id()
|
||||
));
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let password = "TestTamperedMacPassword2026!";
|
||||
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(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
// Erstelle einen Testknoten
|
||||
let node = db.create_node(1, "original.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
|
||||
// Vor Manipulation: verify_metadata_mac muss erfolgreich sein
|
||||
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
// Manipuliere SQLite-Metadaten direkt (Böswilliger Angreifer)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE nodes SET name = 'hacked.exe' WHERE id = ?1",
|
||||
[node.id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Nach Manipulation: Direkte Verifikation meldet 'false' (Integritätsbruch)
|
||||
assert!(!db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
drop(db);
|
||||
|
||||
// Aufruf von mount_container muss fail-closed abbrechen
|
||||
let auth =
|
||||
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
|
||||
let res = sanctum::mount::mount_container(
|
||||
&container_path,
|
||||
'Z',
|
||||
None,
|
||||
Some(18943),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Mount mit manipulierten Metadaten muss fehlschlagen!"
|
||||
);
|
||||
let err_msg = res.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Metadaten-MAC-Verifikation fehlgeschlagen") || err_msg.contains("K-01"),
|
||||
"Erwartete Fehlermeldung zu K-01 Metadaten-MAC, erhalten: {}",
|
||||
err_msg
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
use sanctum::sync::validate_node_name;
|
||||
|
||||
#[test]
|
||||
fn test_validate_node_name_rejections() {
|
||||
// 1. Leere Namen
|
||||
assert!(validate_node_name("").is_err());
|
||||
|
||||
// 2. Relative Verzeichnisreferenzen
|
||||
assert!(validate_node_name(".").is_err());
|
||||
assert!(validate_node_name("..").is_err());
|
||||
|
||||
// 3. Pfadtrenner
|
||||
assert!(validate_node_name("foo/bar").is_err());
|
||||
assert!(validate_node_name("foo\\bar").is_err());
|
||||
assert!(validate_node_name("../evil.exe").is_err());
|
||||
assert!(validate_node_name("..\\evil.exe").is_err());
|
||||
assert!(validate_node_name("/root_file").is_err());
|
||||
|
||||
// 4. Null-Bytes
|
||||
assert!(validate_node_name("test\0file.txt").is_err());
|
||||
|
||||
// 5. Steuerzeichen (< 0x20)
|
||||
assert!(validate_node_name("line\nbreak.txt").is_err());
|
||||
assert!(validate_node_name("carriage\rreturn.txt").is_err());
|
||||
assert!(validate_node_name("tab\tseparated.txt").is_err());
|
||||
assert!(validate_node_name("control\x01char.txt").is_err());
|
||||
|
||||
// 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",
|
||||
];
|
||||
for res in reserved_names {
|
||||
assert!(
|
||||
validate_node_name(res).is_err(),
|
||||
"Reservierter Name '{}' muss abgewiesen werden",
|
||||
res
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_node_name_accepted() {
|
||||
let valid_names = [
|
||||
"document.pdf",
|
||||
"my_notes_2026.txt",
|
||||
"archive.tar.gz",
|
||||
"spaces are allowed.docx",
|
||||
"unicode_äöü_ß_test.rs",
|
||||
"hyphens-and_underscores.json",
|
||||
"file (1).jpg",
|
||||
"normal_file",
|
||||
];
|
||||
for valid in valid_names {
|
||||
assert!(
|
||||
validate_node_name(valid).is_ok(),
|
||||
"Gültiger Name '{}' sollte akzeptiert werden",
|
||||
valid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_carrier_fs_rejects_invalid_names() {
|
||||
use dav_server::davpath::DavPath;
|
||||
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sanctum::carrier::CarrierFs;
|
||||
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;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
let id: u64 = OsRng.next_u64();
|
||||
path.push(format!("sanctum_test_carrier_pathval_{}.sanctum", id));
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
let carrier_size_bytes = 10 * 1024 * 1024;
|
||||
let carrier_name = "system_backup.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");
|
||||
|
||||
let cfs = CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
Arc::new(dek_0),
|
||||
Arc::new(dek_1),
|
||||
3,
|
||||
false,
|
||||
)
|
||||
.expect("Load CarrierFs");
|
||||
|
||||
// 1. create_dir mit ungültigem Namen (Windows-Reservierung CON, PRN)
|
||||
let p_con = DavPath::new("/CON").unwrap();
|
||||
assert_eq!(cfs.create_dir(&p_con).await, Err(FsError::Forbidden));
|
||||
|
||||
let p_aux = DavPath::new("/aux.txt").unwrap();
|
||||
assert_eq!(cfs.create_dir(&p_aux).await, Err(FsError::Forbidden));
|
||||
|
||||
// 2. open (create) mit ungültigem Namen
|
||||
let p_nul = DavPath::new("/NUL").unwrap();
|
||||
let mut opt_create = OpenOptions::default();
|
||||
opt_create.create = true;
|
||||
opt_create.write = true;
|
||||
assert_eq!(
|
||||
cfs.open(&p_nul, opt_create.clone()).await.err(),
|
||||
Some(FsError::Forbidden)
|
||||
);
|
||||
|
||||
let p_ctrl = DavPath::new("/prn.pdf").unwrap();
|
||||
assert_eq!(
|
||||
cfs.open(&p_ctrl, opt_create.clone()).await.err(),
|
||||
Some(FsError::Forbidden)
|
||||
);
|
||||
|
||||
// 3. Gültige Datei erstellen
|
||||
let p_valid = DavPath::new("/valid.txt").unwrap();
|
||||
let file = cfs.open(&p_valid, opt_create).await;
|
||||
assert!(file.is_ok(), "Valid file should be created");
|
||||
drop(file);
|
||||
|
||||
// 4. rename mit ungültigem Zielnamen
|
||||
let p_com1 = DavPath::new("/COM1.txt").unwrap();
|
||||
assert_eq!(cfs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
|
||||
|
||||
// 5. copy mit ungültigem Zielnamen
|
||||
let p_lpt1 = DavPath::new("/LPT1").unwrap();
|
||||
assert_eq!(cfs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_vfs_rejects_invalid_names() {
|
||||
use dav_server::davpath::DavPath;
|
||||
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
||||
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::vfs::SanctumFs;
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
let id: u64 = OsRng.next_u64();
|
||||
path.push(format!("sanctum_test_vfs_pathval_{}.sanctum", id));
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
let password = "TestPassword1234!";
|
||||
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, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
let fs = SanctumFs::new(db.clone(), dek, FORMAT_VERSION);
|
||||
|
||||
// 1. create_dir mit ungültigem Namen
|
||||
let p_con = DavPath::new("/CON").unwrap();
|
||||
assert_eq!(fs.create_dir(&p_con).await, Err(FsError::Forbidden));
|
||||
|
||||
let p_aux = DavPath::new("/aux.txt").unwrap();
|
||||
assert_eq!(fs.create_dir(&p_aux).await, Err(FsError::Forbidden));
|
||||
|
||||
// 2. open (create) mit ungültigem Namen
|
||||
let p_nul = DavPath::new("/NUL").unwrap();
|
||||
let mut opt_create = OpenOptions::default();
|
||||
opt_create.create = true;
|
||||
opt_create.write = true;
|
||||
assert_eq!(
|
||||
fs.open(&p_nul, opt_create.clone()).await.err(),
|
||||
Some(FsError::Forbidden)
|
||||
);
|
||||
|
||||
let p_prn = DavPath::new("/prn.pdf").unwrap();
|
||||
assert_eq!(
|
||||
fs.open(&p_prn, opt_create.clone()).await.err(),
|
||||
Some(FsError::Forbidden)
|
||||
);
|
||||
|
||||
// 3. Gültige Datei erstellen
|
||||
let p_valid = DavPath::new("/valid.txt").unwrap();
|
||||
let file = fs.open(&p_valid, opt_create).await;
|
||||
assert!(file.is_ok(), "Valid file should be created");
|
||||
drop(file);
|
||||
|
||||
// 4. rename mit ungültigem Zielnamen
|
||||
let p_com1 = DavPath::new("/COM1.txt").unwrap();
|
||||
assert_eq!(fs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
|
||||
|
||||
// 5. copy mit ungültigem Zielnamen
|
||||
let p_lpt1 = DavPath::new("/LPT1").unwrap();
|
||||
assert_eq!(fs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
|
||||
|
||||
drop(fs);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
|
||||
|
||||
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_{}_{}.sanctum", prefix, id));
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_carrier_protection_in_storage_and_sync() {
|
||||
let path = temp_db_path("carrier_sec");
|
||||
let carrier_name = "secret_carrier.iso";
|
||||
let carrier_size_bytes = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
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");
|
||||
|
||||
let created_cid = 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();
|
||||
assert_eq!(created_cid, Some(carrier_node_id));
|
||||
|
||||
// 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.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"));
|
||||
|
||||
// 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"));
|
||||
|
||||
// 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,
|
||||
0, // Decoy Vault ID
|
||||
&dek_0,
|
||||
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"
|
||||
);
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
use sanctum::crypto::{
|
||||
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() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path: PathBuf = temp_dir.join(format!("test_foreign_sqlite_{}.db", std::process::id()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", [])
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO users (name) VALUES ('Alice')", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
|
||||
let res = Database::open(&db_path);
|
||||
match res {
|
||||
Err(err) => {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("kein gültiger Sanctum-Container"),
|
||||
"Fehler muss ungültigen Container abweisen: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Fremde SQLite-DB ohne meta-Tabelle muss abgelehnt werden"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_sqlite_database_with_invalid_magic() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path: PathBuf = temp_dir.join(format!("test_invalid_magic_{}.db", std::process::id()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// Erstelle SQLite DB mit 'meta' Tabelle, aber falscher Magic
|
||||
{
|
||||
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(
|
||||
"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)",
|
||||
rusqlite::params![
|
||||
b"EVIL_MAGIC",
|
||||
&[0u8; 16][..],
|
||||
&[0u8; 40][..],
|
||||
&[0u8; 12][..],
|
||||
&[0u8; 16][..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let res = Database::open(&db_path);
|
||||
match res {
|
||||
Err(err) => {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("kein gültiger Sanctum-Container"),
|
||||
"Fehler muss ungültigen Magic-Header monieren: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Container mit ungültigen Magic Bytes muss abgelehnt werden"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_out_of_bounds_kdf_params() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path: PathBuf = temp_dir.join(format!("test_oob_kdf_{}.sanctum", std::process::id()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 1. Initialisiere gültigen Container
|
||||
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("ValidPassword2026!", &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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// 2. Manipuliere KDF-Parameter in der meta-Tabelle auf gefährliche Werte (DoS: 16 GiB Memory)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET kdf_params = '{\"memory_cost\": 16777216, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 3. Öffnen und read_slots() aufrufen: muss mit Kdf-Param-Fehler abbrechen
|
||||
let db_reopened = Database::open(&db_path).unwrap();
|
||||
let slots_res = db_reopened.read_slots();
|
||||
match slots_res {
|
||||
Err(err) => {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("KDF-Parameter"),
|
||||
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Überhöhte KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||
}
|
||||
|
||||
// 4. Manipuliere KDF-Parameter auf zu schwache Werte (< MIN_MEMORY_COST_KIB)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET kdf_params = '{\"memory_cost\": 1024, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let slots_res2 = db_reopened.read_slots();
|
||||
match slots_res2 {
|
||||
Err(err) => {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("KDF-Parameter"),
|
||||
"Fehler muss KDF-Parameter monieren: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Zu schwache KDF-Parameter müssen fail-closed abgewehrt werden"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[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()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_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("ValidPassword2026!", &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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Manipuliere Salt-Länge auf 8 Byte statt 16 Byte
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET kdf_salt = ?1 WHERE slot_id = 0",
|
||||
[&[0u8; 8][..]],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db_reopened = Database::open(&db_path).unwrap();
|
||||
let res = db_reopened.read_slots();
|
||||
match res {
|
||||
Err(err) => {
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Salt-Länge"),
|
||||
"Fehler muss fehlerhafte Salt-Länge monieren: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("Ungültige Salt-Länge muss abgewiesen werden"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_slot_amplification_and_excessive_slots_dos() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||
|
||||
// 1. Fall: 0 Slots (leere meta-Tabelle)
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
|
||||
.unwrap();
|
||||
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err());
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("leer oder beschädigt"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
|
||||
.unwrap();
|
||||
}
|
||||
let db_reopened = Database::open(&db_path).unwrap();
|
||||
let slots = db_reopened
|
||||
.read_slots()
|
||||
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0].slot_id, 0);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
let slots = db
|
||||
.read_slots()
|
||||
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
|
||||
assert_eq!(slots.len(), 2);
|
||||
assert_eq!(slots[0].slot_id, 0);
|
||||
assert_eq!(slots[1].slot_id, 1);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"Database::open muss 3 Slots sofort abweisen"
|
||||
);
|
||||
let err_msg = open_res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"100 Slots müssen sofort abgewiesen werden"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
|
||||
.unwrap();
|
||||
for i in 0..1000 {
|
||||
stmt.execute(rusqlite::params![
|
||||
i,
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let open_res = Database::open(&db_path);
|
||||
assert!(
|
||||
open_res.is_err(),
|
||||
"1000 Slots müssen sofort abgewiesen werden"
|
||||
);
|
||||
let err_msg = open_res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-Anzahl"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_duplicate_and_invalid_slot_ids() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||
|
||||
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
|
||||
{
|
||||
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Doppelte Slot-ID 0"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
|
||||
{
|
||||
let db_path = temp_dir.join(format!(
|
||||
"test_invalid_slot_id_{}.sanctum",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Ungültige Slot-ID 5"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
|
||||
{
|
||||
let db_path = temp_dir.join(format!(
|
||||
"test_missing_slot_0_{}.sanctum",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
|
||||
);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
b"SANCTUM\0",
|
||||
&salt[..],
|
||||
¶ms_json,
|
||||
&wrapped_dek[..],
|
||||
&nonce[..],
|
||||
&tag[..]
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let res = db.read_slots();
|
||||
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
|
||||
"Fehlermeldung: {err_msg}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_meta_authenticate_defense_in_depth() {
|
||||
use sanctum::storage::{ContainerMeta, SlotMeta};
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek("TestPassword2026!", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let valid_slot_0 = SlotMeta {
|
||||
slot_id: 0,
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
};
|
||||
|
||||
let valid_slot_1 = SlotMeta {
|
||||
slot_id: 1,
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
};
|
||||
|
||||
// A. Leere Slots -> sofort None
|
||||
let meta_empty = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![],
|
||||
};
|
||||
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
|
||||
let meta_3_slots = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![
|
||||
valid_slot_0.clone(),
|
||||
valid_slot_1.clone(),
|
||||
valid_slot_0.clone(),
|
||||
],
|
||||
};
|
||||
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
|
||||
let meta_no_slot0 = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_1.clone()],
|
||||
};
|
||||
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// D. Ungültige Slot-ID (> 1) -> sofort None
|
||||
let mut invalid_slot = valid_slot_0.clone();
|
||||
invalid_slot.slot_id = 99;
|
||||
let meta_invalid_id = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_0.clone(), invalid_slot],
|
||||
};
|
||||
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
|
||||
|
||||
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
|
||||
let meta_valid_1 = ContainerMeta {
|
||||
version: 2,
|
||||
kdf_salt: salt,
|
||||
kdf_params: kdf_params.clone(),
|
||||
wrapped_dek: wrapped_dek.clone(),
|
||||
header_nonce: nonce,
|
||||
header_tag: tag,
|
||||
slots: vec![valid_slot_0.clone()],
|
||||
};
|
||||
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_k01_metadata_tampering_detected_by_verify() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
let password = "SecretMasterPassword2026!";
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let salt = generate_salt();
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
||||
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.set_active_dek(dek.clone());
|
||||
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
|
||||
.unwrap();
|
||||
|
||||
// Knoten anlegen
|
||||
let node = db.create_node(1, "secret_financials.xlsx", false).unwrap();
|
||||
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
|
||||
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||
assert!(
|
||||
report.is_healthy(),
|
||||
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
|
||||
report.errors
|
||||
);
|
||||
|
||||
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
|
||||
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||
assert!(
|
||||
!tampered_report.is_healthy(),
|
||||
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
|
||||
);
|
||||
assert!(
|
||||
tampered_report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
||||
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
||||
tampered_report.errors
|
||||
);
|
||||
|
||||
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
|
||||
[node.id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||
assert!(
|
||||
!tampered_name_report.is_healthy(),
|
||||
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
|
||||
);
|
||||
assert!(
|
||||
tampered_name_report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
||||
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
||||
tampered_name_report.errors
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_k01_upgrade_format_v2_to_v3() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path: PathBuf =
|
||||
temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
|
||||
if db_path.exists() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
let password = "UpgradeMasterPassword2026!";
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let salt = generate_salt();
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
||||
|
||||
// Erzeuge bewusst einen V2-Container ohne metadata_mac
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER NOT NULL PRIMARY KEY,
|
||||
magic BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL,
|
||||
kdf_params TEXT NOT NULL,
|
||||
wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL,
|
||||
header_tag BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
is_carrier INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE chunks (
|
||||
node_id INTEGER NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
tag BLOB NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
PRIMARY KEY (node_id, chunk_index)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
||||
VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
sanctum::crypto::MAGIC_BYTES.as_slice(),
|
||||
salt.as_slice(),
|
||||
params_json,
|
||||
wrapped_dek,
|
||||
nonce.as_slice(),
|
||||
tag.as_slice(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
|
||||
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
let meta_before = db.read_meta().unwrap();
|
||||
assert_eq!(meta_before.version, 2);
|
||||
|
||||
// Upgrade auf Format V3 durchführen
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let meta_after = db.read_meta().unwrap();
|
||||
assert_eq!(meta_after.version, 3);
|
||||
|
||||
// V3-Container muss nach dem Upgrade integer und gesund sein
|
||||
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||
assert!(
|
||||
report.is_healthy(),
|
||||
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
|
||||
report.errors
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_k02_chunk_replay_detected_by_vfs_and_crypto() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path = temp_dir.join(format!(
|
||||
"k02_replay_test_{}.sanctum",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
|
||||
let password = "TestPasswordK02!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.set_active_dek(dek.clone());
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// 1. Datei im Format V3 anlegen
|
||||
let file = db.create_node(1, "replay_target.txt", false).unwrap();
|
||||
let initial_data = b"State 1: Initial secret content in chunk 0.";
|
||||
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||
let (ct1, nonce1, tag1) = encrypt_chunk(
|
||||
&dek,
|
||||
file.id,
|
||||
0,
|
||||
initial_data,
|
||||
sanctum::crypto::FORMAT_VERSION_V3,
|
||||
gen1,
|
||||
)
|
||||
.unwrap();
|
||||
db.write_chunk_and_update_size(
|
||||
file.id,
|
||||
0,
|
||||
gen1,
|
||||
&nonce1,
|
||||
&tag1,
|
||||
&ct1,
|
||||
initial_data.len() as u64,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Ciphertext-Zeile von Zustand 1 sichern (Nonce, Tag, Ciphertext)
|
||||
let saved_chunk1 = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||
assert_eq!(saved_chunk1.generation, gen1);
|
||||
|
||||
// 2. Chunk mit neuem Inhalt überschreiben (Zustand 2)
|
||||
let updated_data = b"State 2: Updated overwritten content in chunk 0.";
|
||||
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||
assert!(gen2 > gen1, "Generation muss monoton steigen");
|
||||
let (ct2, nonce2, tag2) = encrypt_chunk(
|
||||
&dek,
|
||||
file.id,
|
||||
0,
|
||||
updated_data,
|
||||
sanctum::crypto::FORMAT_VERSION_V3,
|
||||
gen2,
|
||||
)
|
||||
.unwrap();
|
||||
db.write_chunk_and_update_size(
|
||||
file.id,
|
||||
0,
|
||||
gen2,
|
||||
&nonce2,
|
||||
&tag2,
|
||||
&ct2,
|
||||
updated_data.len() as u64,
|
||||
2000,
|
||||
)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let current_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||
assert_eq!(current_chunk.generation, gen2);
|
||||
|
||||
// 3. Replay-Angriff: Angreifer spielt alte Ciphertext-Zeile von Zustand 1 zurück in die SQLite-Tabelle
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = 0",
|
||||
rusqlite::params![
|
||||
saved_chunk1.nonce.as_slice(),
|
||||
saved_chunk1.tag.as_slice(),
|
||||
saved_chunk1.ciphertext,
|
||||
file.id,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Entschlüsselungsversuch muss fehlschlagen (AEAD Auth-Fehler wegen AAD-Generationsabweichung)
|
||||
let replayed_chunk = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||
let decrypt_res = decrypt_chunk(
|
||||
&dek,
|
||||
file.id,
|
||||
0,
|
||||
&replayed_chunk.ciphertext,
|
||||
&replayed_chunk.nonce,
|
||||
&replayed_chunk.tag,
|
||||
sanctum::crypto::FORMAT_VERSION_V3,
|
||||
replayed_chunk.generation,
|
||||
);
|
||||
assert!(
|
||||
decrypt_res.is_err(),
|
||||
"K-02: Replay von altem Ciphertext in aktuellem Chunk-Slot muss durch AEAD AAD-Mismatch abgewiesen werden!"
|
||||
);
|
||||
|
||||
// Verify muss Replay/Manipulierte Chunks erkennen
|
||||
let verify_res = verify_container(&db_path, Some(&dek), true).unwrap();
|
||||
assert!(
|
||||
!verify_res.is_healthy() || verify_res.corrupted_chunks > 0,
|
||||
"Verify muss Replay/Manipulierte Chunks erkennen"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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]
|
||||
fn test_minisign_signature_valid() {
|
||||
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";
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minisign_signature_tampered_payload() {
|
||||
let tampered_payload = b"Hello Sanctum Release 0.7.0 Tampered Payload!\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";
|
||||
|
||||
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.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minisign_signature_wrong_key() {
|
||||
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";
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
|
||||
let options = UpgradeOptions {
|
||||
check_only: true,
|
||||
yes: false,
|
||||
force: false,
|
||||
base_url: "https://evil-attacker.org/sanctum".into(),
|
||||
insecure_url: false,
|
||||
};
|
||||
|
||||
let res = run_upgrade(&options);
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use sanctum::upgrade::{
|
||||
detect_package_manager_from_path, is_newer_version, parse_checksum_for_asset,
|
||||
parse_clean_version, GiteaRelease, PackageManager,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn test_parse_checksum_for_asset() {
|
||||
let sample_checksums = r#"
|
||||
# Offizielle SHA-256 Prüfsummen für Sanctum Release
|
||||
2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e sanctum-v0.5.0-windows-x86_64.zip
|
||||
e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63 sanctum.exe
|
||||
557dd84f339336899fdd67e69d894dcd6e32f3e0bff2ae8a3057df596eddf4b4 sanctum-v0.5.0-linux-x86_64.tar.gz
|
||||
2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d *sanctum
|
||||
"#;
|
||||
|
||||
// Windows Binary
|
||||
let win_hash = parse_checksum_for_asset(sample_checksums, "sanctum.exe");
|
||||
assert_eq!(
|
||||
win_hash.as_deref(),
|
||||
Some("e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63")
|
||||
);
|
||||
|
||||
// Linux Binary (mit führendem Asterisk im Format)
|
||||
let linux_hash = parse_checksum_for_asset(sample_checksums, "sanctum");
|
||||
assert_eq!(
|
||||
linux_hash.as_deref(),
|
||||
Some("2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d")
|
||||
);
|
||||
|
||||
// Nicht existentes Asset
|
||||
let non_existent = parse_checksum_for_asset(sample_checksums, "nonexistent.exe");
|
||||
assert_eq!(non_existent, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_semver_comparisons() {
|
||||
// Normaler Versionssprung
|
||||
assert!(is_newer_version("0.5.0", "0.6.0"));
|
||||
assert!(is_newer_version("0.5.0", "v0.6.0"));
|
||||
assert!(is_newer_version("v0.5.0", "V0.5.1"));
|
||||
assert!(is_newer_version("0.5.0", "1.0.0"));
|
||||
|
||||
// Gleiche Version
|
||||
assert!(!is_newer_version("0.6.0", "0.6.0"));
|
||||
assert!(!is_newer_version("0.6.0", "v0.6.0"));
|
||||
assert!(!is_newer_version("v0.6.0", "0.6.0"));
|
||||
|
||||
// Ältere Version
|
||||
assert!(!is_newer_version("0.6.0", "0.5.0"));
|
||||
assert!(!is_newer_version("0.6.0", "v0.4.1"));
|
||||
|
||||
// Parsing mit führenden 'v' / 'V'
|
||||
let v1 = parse_clean_version("v0.6.0").expect("Parse v0.6.0");
|
||||
let v2 = parse_clean_version("V1.2.3").expect("Parse V1.2.3");
|
||||
assert_eq!(v1.major, 0);
|
||||
assert_eq!(v1.minor, 6);
|
||||
assert_eq!(v2.major, 1);
|
||||
assert_eq!(v2.minor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_manager_detection() {
|
||||
// Scoop Pfade
|
||||
let scoop_path = Path::new(r"C:\Users\harald\scoop\apps\sanctum\current\sanctum.exe");
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(scoop_path),
|
||||
Some(PackageManager::Scoop)
|
||||
);
|
||||
|
||||
let scoop_shim = Path::new(r"C:\Users\harald\scoop\shims\sanctum.exe");
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(scoop_shim),
|
||||
Some(PackageManager::Scoop)
|
||||
);
|
||||
|
||||
// Winget Pfade
|
||||
let winget_path = Path::new(
|
||||
r"C:\Users\harald\AppData\Local\Microsoft\WinGet\Packages\HaraldPansi.Sanctum_Microsoft.Winget.Source_8wekyb3d8bbwe\sanctum.exe",
|
||||
);
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(winget_path),
|
||||
Some(PackageManager::Winget)
|
||||
);
|
||||
|
||||
// Standard Standalone Pfade (kein Paketmanager)
|
||||
let standalone = Path::new(r"C:\Tools\sanctum\sanctum.exe");
|
||||
assert_eq!(detect_package_manager_from_path(standalone), None);
|
||||
|
||||
let linux_usr = Path::new("/usr/local/bin/sanctum");
|
||||
assert_eq!(detect_package_manager_from_path(linux_usr), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_integrity_calculation() {
|
||||
let test_file = std::env::temp_dir().join(format!("sanctum_test_{}.bin", std::process::id()));
|
||||
|
||||
let payload = b"Sanctum Standalone Binary Payload for Integrity Testing";
|
||||
{
|
||||
let mut f = File::create(&test_file).expect("create file");
|
||||
f.write_all(payload).expect("write");
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload);
|
||||
let expected = hex::encode(hasher.finalize());
|
||||
|
||||
// Datei erneut lesen und hashen
|
||||
let mut reader = File::open(&test_file).expect("open");
|
||||
let mut file_hasher = Sha256::new();
|
||||
std::io::copy(&mut reader, &mut file_hasher).expect("copy");
|
||||
let calculated = hex::encode(file_hasher.finalize());
|
||||
|
||||
assert_eq!(expected, calculated);
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_deserialization_of_gitea_release() {
|
||||
let gitea_json = r##"{
|
||||
"id": 27,
|
||||
"tag_name": "v0.5.0",
|
||||
"name": "Sanctum v0.5.0 (Windows x86_64)",
|
||||
"body": "Release v0.5.0 - sanctum sync",
|
||||
"html_url": "https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.0",
|
||||
"published_at": "2026-09-16T10:44:18+02:00",
|
||||
"assets": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "sanctum.exe",
|
||||
"size": 5467136,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum.exe"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"name": "sanctum",
|
||||
"size": 5033952,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"name": "SHA256SUMS.txt",
|
||||
"size": 563,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/SHA256SUMS.txt"
|
||||
}
|
||||
]
|
||||
}"##;
|
||||
|
||||
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);
|
||||
assert_eq!(release.assets[0].name, "sanctum.exe");
|
||||
assert_eq!(release.assets[1].name, "sanctum");
|
||||
assert_eq!(release.assets[2].name, "SHA256SUMS.txt");
|
||||
}
|
||||
Reference in New Issue
Block a user