Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf395627a | ||
|
|
fcd59dfe68 | ||
|
|
a8fdfc674e | ||
|
|
70d03df676 | ||
|
|
771c08ff0f | ||
|
|
98bfac718f | ||
|
|
030ce6a1e5 | ||
|
|
b8e4dcb614 | ||
|
|
ea571d245e | ||
|
|
bb4268cb62 | ||
|
|
952e0cb23a | ||
|
|
fb154c0d2e | ||
|
|
5c1ac89989 | ||
|
|
1e6854e9f5 | ||
|
|
2cb065c8c0 | ||
|
|
38df3d3845 | ||
|
|
1c8a860184 | ||
|
|
2d14c64c3e | ||
|
|
a9c3dd25a3 | ||
|
|
58336fc059 | ||
|
|
83dc42dd86 | ||
|
|
38a8a747c7 | ||
|
|
38c0a6dc12 | ||
|
|
d15ebd8916 |
@@ -2,3 +2,7 @@
|
||||
/dist
|
||||
*.sanctum
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
*.token
|
||||
.token
|
||||
|
||||
+60
-1
@@ -5,7 +5,66 @@ 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.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
+333
-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]]
|
||||
@@ -421,6 +524,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 +726,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"
|
||||
@@ -896,6 +1041,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"
|
||||
@@ -935,7 +1098,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -944,7 +1107,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 +1119,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 +1166,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 +1314,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1144,7 +1342,7 @@ checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1154,7 +1352,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 +1361,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",
|
||||
@@ -1179,19 +1377,22 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"bip39",
|
||||
"bytes",
|
||||
"clap",
|
||||
"dav-server",
|
||||
"dyn-clone",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"lz4_flex",
|
||||
"rand",
|
||||
"rpassword",
|
||||
"rusqlite",
|
||||
@@ -1201,6 +1402,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tray-item",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1308,7 +1510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1401,6 +1603,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 +1632,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1504,6 +1721,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 +1761,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"
|
||||
@@ -1695,6 +1943,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 +1961,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"
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
@@ -18,7 +18,7 @@ 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,6 +33,10 @@ 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"
|
||||
tray-item = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,42 +1,58 @@
|
||||
<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 10/11 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 im reinen Userland verwaltet.
|
||||
|
||||
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
|
||||
- **Windows Explorer-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon.
|
||||
- **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.
|
||||
- **Plausible Deniability (Hidden Safe)**: Dual-Slot-Header. Ein unbenutzter Slot enthält uniformes CSPRNG-Rauschen, das sich nicht von einem echten Hidden Vault unterscheiden lässt. Volle Dateinamen-Verschlüsselung im Hidden Vault.
|
||||
- **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**: `sanctum.exe` (~5.3 MB) ohne externe DLL-Abhängigkeiten.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 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)**:
|
||||
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.
|
||||
- **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.
|
||||
- **Plausible Deniability (Multi-Slot)**:
|
||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Hidden Vault oder CSPRNG-Dummy. Ein Angreifer kann mathematisch nicht feststellen, ob Slot 1 ungenutzt ist oder einen zweiten Tresor birgt.
|
||||
- **Dateinamen-Verschlüsselung**:
|
||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert.
|
||||
- **Kryptografisches Chunk-Shredding**:
|
||||
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
|
||||
|
||||
---
|
||||
|
||||
## 📦 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,37 +68,119 @@ 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 Plausible Deniability (Decoy + Hidden 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
|
||||
```
|
||||
- **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 automatisch im Explorer geöffnet.
|
||||
- **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
|
||||
|
||||
Um ein vollständiges Release-Paket mit Tests, komprimiertem ZIP-Archiv und SHA-256 Prüfsummen zu erstellen:
|
||||
```powershell
|
||||
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 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.
|
||||
|
||||
---
|
||||
|
||||
## 📦 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.3.0-windows-x86_64.zip`
|
||||
- `dist/SHA256SUMS.txt`
|
||||
|
||||
---
|
||||
@@ -91,3 +189,4 @@ Das fertige Paket liegt in `dist/` bereit:
|
||||
|
||||
- Lizenziert unter der [MIT License](LICENSE).
|
||||
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ 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 "LICENSE") (Join-Path $StagingDir "LICENSE")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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")
|
||||
}
|
||||
|
||||
# .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"
|
||||
|
||||
# ZIP Asset hochladen
|
||||
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan
|
||||
$ZipResult = & curl.exe -s -X POST "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip" `
|
||||
-H "Authorization: token $Token" `
|
||||
-F "attachment=@$ZipFile"
|
||||
|
||||
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green
|
||||
|
||||
# SHA256SUMS.txt hochladen
|
||||
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
|
||||
$SumResult = & curl.exe -s -X POST "$UploadUrl?name=SHA256SUMS.txt" `
|
||||
-H "Authorization: token $Token" `
|
||||
-F "attachment=@$ChecksumFile"
|
||||
|
||||
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
|
||||
|
||||
|
||||
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"
|
||||
+1035
File diff suppressed because it is too large
Load Diff
+530
-26
@@ -10,9 +10,15 @@ use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
pub const FORMAT_VERSION_V1: u32 = 1;
|
||||
pub const FORMAT_VERSION_V2: u32 = 2;
|
||||
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2;
|
||||
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
|
||||
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
|
||||
pub const COMPRESSION_NONE: u8 = 0x00;
|
||||
pub const COMPRESSION_LZ4: u8 = 0x01;
|
||||
|
||||
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
|
||||
pub const DEFAULT_TIME_COST: u32 = 3;
|
||||
pub const DEFAULT_PARALLELISM: u32 = 4;
|
||||
@@ -72,11 +78,10 @@ pub fn generate_salt() -> [u8; 16] {
|
||||
salt
|
||||
}
|
||||
|
||||
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM.
|
||||
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
|
||||
pub fn wrap_dek(
|
||||
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
|
||||
pub fn wrap_key_payload(
|
||||
kek: &[u8; 32],
|
||||
dek: &[u8; 32],
|
||||
payload: &[u8],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
@@ -85,10 +90,10 @@ pub fn wrap_dek(
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut buffer = dek.to_vec();
|
||||
let mut buffer = payload.to_vec();
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
|
||||
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?;
|
||||
.map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?;
|
||||
|
||||
let mut tag_bytes = [0u8; 16];
|
||||
tag_bytes.copy_from_slice(tag.as_slice());
|
||||
@@ -96,6 +101,36 @@ pub fn wrap_dek(
|
||||
Ok((buffer, nonce_bytes, tag_bytes))
|
||||
}
|
||||
|
||||
/// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität.
|
||||
pub fn unwrap_key_payload(
|
||||
kek: &[u8; 32],
|
||||
wrapped_payload: &[u8],
|
||||
nonce_bytes: &[u8; 12],
|
||||
tag_bytes: &[u8; 16],
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let tag = Tag::from_slice(tag_bytes);
|
||||
|
||||
let mut buffer = wrapped_payload.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
|
||||
|
||||
Ok(Zeroizing::new(buffer))
|
||||
}
|
||||
|
||||
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
|
||||
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
|
||||
pub fn wrap_dek(
|
||||
kek: &[u8; 32],
|
||||
dek: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
wrap_key_payload(kek, dek)
|
||||
}
|
||||
|
||||
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
|
||||
pub fn unwrap_dek(
|
||||
kek: &[u8; 32],
|
||||
@@ -103,23 +138,209 @@ pub fn unwrap_dek(
|
||||
nonce_bytes: &[u8; 12],
|
||||
tag_bytes: &[u8; 16],
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
if wrapped_dek.len() != 32 {
|
||||
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len());
|
||||
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
|
||||
if payload.len() < 32 {
|
||||
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
|
||||
}
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
let mut dek = Zeroizing::new([0u8; 32]);
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
Ok(dek)
|
||||
}
|
||||
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let tag = Tag::from_slice(tag_bytes);
|
||||
/// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
|
||||
pub fn wrap_slot0_payload(
|
||||
kek: &[u8; 32],
|
||||
dek_0: &[u8; 32],
|
||||
carrier_node_id: i64,
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let mut payload = Vec::with_capacity(40);
|
||||
payload.extend_from_slice(dek_0);
|
||||
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
|
||||
wrap_key_payload(kek, &payload)
|
||||
}
|
||||
|
||||
let mut buffer = wrapped_dek.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
|
||||
/// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian).
|
||||
pub fn wrap_slot1_payload(
|
||||
kek: &[u8; 32],
|
||||
dek_1: &[u8; 32],
|
||||
dek_0: &[u8; 32],
|
||||
carrier_node_id: i64,
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let mut payload = Vec::with_capacity(72);
|
||||
payload.extend_from_slice(dek_1);
|
||||
payload.extend_from_slice(dek_0);
|
||||
payload.extend_from_slice(&carrier_node_id.to_le_bytes());
|
||||
wrap_key_payload(kek, &payload)
|
||||
}
|
||||
|
||||
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
|
||||
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
|
||||
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
|
||||
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
|
||||
let mut wrapped_dek = vec![0u8; 72];
|
||||
let mut nonce = [0u8; 12];
|
||||
let mut tag = [0u8; 16];
|
||||
let mut salt = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut wrapped_dek);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
OsRng.fill_bytes(&mut tag);
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
(wrapped_dek, nonce, tag, salt)
|
||||
}
|
||||
|
||||
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault,
|
||||
/// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern:
|
||||
/// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian).
|
||||
#[inline]
|
||||
pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
||||
let mut aad = [0u8; 16];
|
||||
aad[..8].copy_from_slice(b"SANCNAME");
|
||||
aad[8..].copy_from_slice(&parent_id.to_le_bytes());
|
||||
aad
|
||||
}
|
||||
|
||||
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
|
||||
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
|
||||
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
|
||||
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
|
||||
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
|
||||
let mut buffer = name.as_bytes().to_vec();
|
||||
let aad = build_name_aad(parent_id);
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
|
||||
.expect("Name encryption");
|
||||
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
combined.extend_from_slice(tag.as_slice());
|
||||
combined.extend_from_slice(&buffer);
|
||||
hex::encode(combined)
|
||||
}
|
||||
|
||||
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
|
||||
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
|
||||
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
|
||||
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
||||
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||
let parts: Vec<&str> = rest.split('$').collect();
|
||||
if parts.len() == 3 {
|
||||
if let (Ok(nonce_bytes), Ok(tag_bytes), Ok(ct_bytes)) = (
|
||||
hex::decode(parts[0]),
|
||||
hex::decode(parts[1]),
|
||||
hex::decode(parts[2]),
|
||||
) {
|
||||
if nonce_bytes.len() == 12 && tag_bytes.len() == 16 {
|
||||
let cipher = Aes256Gcm::new_from_slice(dek).ok()?;
|
||||
// 1. Primär: Authentifizierung mit parent_id AAD
|
||||
let aad = build_name_aad(parent_id);
|
||||
let mut buffer = ct_bytes.clone();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
&aad,
|
||||
&mut buffer,
|
||||
Tag::from_slice(&tag_bytes),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return String::from_utf8(buffer).ok();
|
||||
}
|
||||
|
||||
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
|
||||
let mut buffer_legacy = ct_bytes;
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&mut buffer_legacy,
|
||||
Tag::from_slice(&tag_bytes),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return String::from_utf8(buffer_legacy).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext)
|
||||
if stored.len() >= 56 {
|
||||
if let Ok(bytes) = hex::decode(stored) {
|
||||
if bytes.len() >= 28 {
|
||||
let nonce = &bytes[0..12];
|
||||
let tag = &bytes[12..28];
|
||||
let ct = &bytes[28..];
|
||||
|
||||
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
|
||||
// 1. Primär: Authentifizierung mit parent_id AAD
|
||||
let aad = build_name_aad(parent_id);
|
||||
let mut buffer = ct.to_vec();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(nonce),
|
||||
&aad,
|
||||
&mut buffer,
|
||||
Tag::from_slice(tag),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return String::from_utf8(buffer).ok();
|
||||
}
|
||||
|
||||
// 2. Fallback: Alte statische AAD für bestehende Container
|
||||
let mut buffer_legacy = ct.to_vec();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(nonce),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&mut buffer_legacy,
|
||||
Tag::from_slice(tag),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return String::from_utf8(buffer_legacy).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
|
||||
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(dek)
|
||||
.map_err(|e| anyhow::anyhow!("Fehler beim Erzeugen der BIP-39 Notfallphrase: {e}"))?;
|
||||
Ok(mnemonic.to_string())
|
||||
}
|
||||
|
||||
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
|
||||
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme.
|
||||
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let cleaned = phrase
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
|
||||
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
|
||||
|
||||
let entropy = mnemonic.to_entropy();
|
||||
if entropy.len() != 32 {
|
||||
bail!(
|
||||
"Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}",
|
||||
entropy.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut dek = Zeroizing::new([0u8; 32]);
|
||||
dek.copy_from_slice(&buffer);
|
||||
dek.copy_from_slice(&entropy);
|
||||
Ok(dek)
|
||||
}
|
||||
|
||||
@@ -134,12 +355,15 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
|
||||
/// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
|
||||
/// sofern dadurch eine Größenreduktion erzielt wird.
|
||||
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
|
||||
pub fn encrypt_chunk(
|
||||
dek: &[u8; 32],
|
||||
node_id: i64,
|
||||
chunk_index: u32,
|
||||
plaintext: &[u8],
|
||||
format_version: u32,
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
@@ -150,7 +374,28 @@ pub fn encrypt_chunk(
|
||||
|
||||
let aad = build_chunk_aad(node_id, chunk_index);
|
||||
|
||||
let mut buffer = plaintext.to_vec();
|
||||
let mut buffer = if format_version >= FORMAT_VERSION_V2 {
|
||||
if plaintext.is_empty() {
|
||||
vec![COMPRESSION_NONE]
|
||||
} else {
|
||||
let compressed = lz4_flex::compress_prepend_size(plaintext);
|
||||
// Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag)
|
||||
if compressed.len() + 64 <= plaintext.len() {
|
||||
let mut buf = Vec::with_capacity(compressed.len() + 1);
|
||||
buf.push(COMPRESSION_LZ4);
|
||||
buf.extend_from_slice(&compressed);
|
||||
buf
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(plaintext.len() + 1);
|
||||
buf.push(COMPRESSION_NONE);
|
||||
buf.extend_from_slice(plaintext);
|
||||
buf
|
||||
}
|
||||
}
|
||||
} else {
|
||||
plaintext.to_vec()
|
||||
};
|
||||
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
|
||||
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
|
||||
@@ -162,6 +407,7 @@ pub fn encrypt_chunk(
|
||||
}
|
||||
|
||||
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
|
||||
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
|
||||
pub fn decrypt_chunk(
|
||||
dek: &[u8; 32],
|
||||
node_id: i64,
|
||||
@@ -169,6 +415,7 @@ pub fn decrypt_chunk(
|
||||
ciphertext: &[u8],
|
||||
nonce_bytes: &[u8; 12],
|
||||
tag_bytes: &[u8; 16],
|
||||
format_version: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let cipher = Aes256Gcm::new_from_slice(dek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
@@ -182,9 +429,37 @@ pub fn decrypt_chunk(
|
||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
|
||||
|
||||
Ok(buffer)
|
||||
if format_version >= FORMAT_VERSION_V2 {
|
||||
if buffer.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
match buffer[0] {
|
||||
COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
|
||||
COMPRESSION_LZ4 => {
|
||||
let payload = &buffer[1..];
|
||||
if payload.len() < 4 {
|
||||
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
|
||||
}
|
||||
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
|
||||
if uncompressed_size > CHUNK_SIZE {
|
||||
bail!(
|
||||
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
|
||||
uncompressed_size,
|
||||
CHUNK_SIZE
|
||||
);
|
||||
}
|
||||
let decompressed = lz4_flex::decompress_size_prepended(payload)
|
||||
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
other => bail!("Unbekannte Chunk-Kompressionsmethode: 0x{:02x}", other),
|
||||
}
|
||||
} else {
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -226,23 +501,252 @@ mod tests {
|
||||
let node_id = 42i64;
|
||||
let chunk_index = 0u32;
|
||||
|
||||
let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext).unwrap();
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
||||
|
||||
// Reguläre Entschlüsselung
|
||||
let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag).unwrap();
|
||||
// Reguläre Entschlüsselung (v2)
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
||||
let swap_node_err = decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag);
|
||||
let swap_node_err =
|
||||
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
assert!(swap_node_err.is_err());
|
||||
|
||||
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
||||
let swap_idx_err = decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag);
|
||||
let swap_idx_err =
|
||||
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
assert!(swap_idx_err.is_err());
|
||||
|
||||
// Manipulation des Ciphertexts
|
||||
let mut tampered_ct = ciphertext.clone();
|
||||
tampered_ct[0] ^= 0x01;
|
||||
assert!(decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag).is_err());
|
||||
assert!(
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lz4_chunk_compression_efficiency() {
|
||||
let dek = generate_dek();
|
||||
// Stark komprimierbarer Text (z.B. Logdatei, JSON, Quellcode)
|
||||
let repeated_text = "Sanctum Secure Vault Storage System ".repeat(500);
|
||||
let plaintext = repeated_text.as_bytes();
|
||||
let node_id = 10i64;
|
||||
let chunk_index = 0u32;
|
||||
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
||||
|
||||
// Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
|
||||
assert!(
|
||||
ciphertext.len() < plaintext.len() / 5,
|
||||
"Ciphertext ({}) sollte drastisch kleiner als Plaintext ({}) sein",
|
||||
ciphertext.len(),
|
||||
plaintext.len()
|
||||
);
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lz4_chunk_compression_threshold() {
|
||||
let dek = generate_dek();
|
||||
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
|
||||
let mut random_bytes = vec![0u8; 1000];
|
||||
OsRng.fill_bytes(&mut random_bytes);
|
||||
|
||||
let (ct, nonce, tag) =
|
||||
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
||||
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
||||
assert_eq!(ct.len(), random_bytes.len() + 1);
|
||||
|
||||
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
assert_eq!(decrypted, random_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v1_backward_compatibility() {
|
||||
let dek = generate_dek();
|
||||
let plaintext = b"Uncompressed Legacy V1 Chunk Payload";
|
||||
let node_id = 5i64;
|
||||
let chunk_index = 0u32;
|
||||
|
||||
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
|
||||
assert_eq!(ciphertext.len(), plaintext.len());
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bip39_recovery_phrase_roundtrip() {
|
||||
let dek = generate_dek();
|
||||
let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic");
|
||||
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
||||
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
|
||||
|
||||
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
|
||||
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
|
||||
|
||||
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
|
||||
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
|
||||
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
|
||||
assert_eq!(*dek, *recovered_messy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bip39_invalid_words_and_checksum() {
|
||||
// 1. Nicht im Wörterbuch enthaltenes Wort
|
||||
let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword";
|
||||
assert!(mnemonic_to_dek(invalid_word_phrase).is_err());
|
||||
|
||||
// 2. Falsche Wortanzahl (z. B. 23 statt 24)
|
||||
let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow";
|
||||
assert!(mnemonic_to_dek(short_phrase).is_err());
|
||||
|
||||
// 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert)
|
||||
let dek = generate_dek();
|
||||
let mut words: Vec<String> = dek_to_mnemonic(&dek)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
|
||||
let original_last = words[23].clone();
|
||||
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
|
||||
let corrupted_phrase = words.join(" ");
|
||||
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hidden_node_name_encryption_and_dummy_slot() {
|
||||
let dek = generate_dek();
|
||||
let filename = "ultra_geheimes_dokument.pdf";
|
||||
let parent_id = 2i64;
|
||||
let encrypted = encrypt_node_name(&dek, parent_id, filename);
|
||||
// Kein verräterisches Präfix mehr! Reines Hex.
|
||||
assert!(!encrypted.starts_with("$h$"));
|
||||
assert!(!encrypted.contains(filename));
|
||||
assert!(encrypted.len() >= 56);
|
||||
|
||||
let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name");
|
||||
assert_eq!(decrypted, filename);
|
||||
|
||||
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
|
||||
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
|
||||
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
assert_eq!(decrypted_legacy, filename);
|
||||
|
||||
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
|
||||
let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap();
|
||||
let mut static_buf = filename.as_bytes().to_vec();
|
||||
let static_nonce = [42u8; 12];
|
||||
let static_tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
|
||||
.unwrap();
|
||||
let legacy_static_format = format!(
|
||||
"$h${}${}${}",
|
||||
hex::encode(static_nonce),
|
||||
hex::encode(static_tag),
|
||||
hex::encode(&static_buf)
|
||||
);
|
||||
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
|
||||
assert_eq!(decrypted_static, filename);
|
||||
|
||||
// Mit anderem DEK schlägt Entschlüsselung fehl
|
||||
let other_dek = generate_dek();
|
||||
assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none());
|
||||
|
||||
// Dummy-Slot hat korrekte Längen (72 Bytes für Modell A)
|
||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||
assert_eq!(dummy_dek.len(), 72);
|
||||
assert_eq!(dummy_nonce.len(), 12);
|
||||
assert_eq!(dummy_tag.len(), 16);
|
||||
assert_eq!(dummy_salt.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_name_aad_parent_binding() {
|
||||
let dek = generate_dek();
|
||||
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||
|
||||
// Gültige parent_ids entschlüsseln erfolgreich
|
||||
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
||||
|
||||
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
|
||||
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_a_slot_payloads() {
|
||||
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
let dek_1 = generate_dek();
|
||||
let carrier_node_id = 42i64;
|
||||
|
||||
// Slot 0 Payload: 40 Bytes
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
assert_eq!(wrapped_0.len(), 40);
|
||||
|
||||
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
|
||||
assert_eq!(unwrapped_0.len(), 40);
|
||||
assert_eq!(&unwrapped_0[0..32], &*dek_0);
|
||||
let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap());
|
||||
assert_eq!(recovered_cid_0, carrier_node_id);
|
||||
|
||||
// Slot 1 Payload: 72 Bytes
|
||||
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
assert_eq!(wrapped_1.len(), 72);
|
||||
|
||||
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
|
||||
assert_eq!(unwrapped_1.len(), 72);
|
||||
assert_eq!(&unwrapped_1[0..32], &*dek_1);
|
||||
assert_eq!(&unwrapped_1[32..64], &*dek_0);
|
||||
let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap());
|
||||
assert_eq!(recovered_cid_1, carrier_node_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lz4_decompression_bomb_protection() {
|
||||
use aes_gcm::KeyInit;
|
||||
let dek = generate_dek();
|
||||
let cipher = Aes256Gcm::new_from_slice(&*dek).unwrap();
|
||||
let node_id = 999;
|
||||
let chunk_index = 0;
|
||||
let aad = build_chunk_aad(node_id, chunk_index);
|
||||
|
||||
// Erstelle präparierte LZ4-Payload mit deklarierter Größe von 5 MB (> 1 MB CHUNK_SIZE)
|
||||
let mut malicious_plaintext = Vec::new();
|
||||
malicious_plaintext.push(COMPRESSION_LZ4);
|
||||
let fake_uncompressed_size: u32 = 5 * 1024 * 1024; // 5 MB
|
||||
malicious_plaintext.extend_from_slice(&fake_uncompressed_size.to_le_bytes());
|
||||
malicious_plaintext.extend_from_slice(&[0u8; 32]); // Dummy-LZ4-Payload
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut ct = malicious_plaintext.clone();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
|
||||
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
|
||||
|
||||
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
|
||||
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
|
||||
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
pub mod carrier;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod recovery;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
pub mod verify;
|
||||
pub mod vfs;
|
||||
pub mod windows;
|
||||
|
||||
+843
-47
@@ -3,11 +3,19 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams};
|
||||
use sanctum::mount::{format_drive, mount_container, unmount_drive};
|
||||
use sanctum::crypto::{
|
||||
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
|
||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
||||
use sanctum::recovery::{
|
||||
export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::ui;
|
||||
use sanctum::verify::verify_container;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sanctum")]
|
||||
@@ -26,6 +34,32 @@ enum Commands {
|
||||
/// Pfad zur zu erstellenden .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
|
||||
#[arg(long, default_value_t = false)]
|
||||
with_hidden: bool,
|
||||
|
||||
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
|
||||
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
|
||||
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
|
||||
/// in forensischen Entropie-Scannern zu verhindern.
|
||||
#[arg(long, default_value = "system_backup.dat")]
|
||||
carrier_name: String,
|
||||
|
||||
/// Größe der Trägerdatei (z. B. 100MB, 500MB, 1GB, 2GB; Standard: 1GB)
|
||||
#[arg(long, default_value = "1GB")]
|
||||
carrier_size: String,
|
||||
},
|
||||
|
||||
/// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
|
||||
Compact {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Maximale Anzahl an Seiten, die freigegeben werden sollen (Standard: alle freien Seiten)
|
||||
#[arg(short, long)]
|
||||
pages: Option<usize>,
|
||||
},
|
||||
|
||||
/// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein
|
||||
@@ -34,13 +68,37 @@ enum Commands {
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:')
|
||||
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt standardmäßig automatisch das nächste freie Laufwerk)
|
||||
#[arg(short, long)]
|
||||
drive: String,
|
||||
drive: Option<String>,
|
||||
|
||||
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
|
||||
#[arg(long)]
|
||||
recovery_key: Option<String>,
|
||||
|
||||
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_open: bool,
|
||||
|
||||
/// Deaktiviert das Windows System-Tray Icon während des Mounts
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_tray: bool,
|
||||
|
||||
/// Automatisches Aushängen und Sperren nach N Sekunden Inaktivität (z. B. 300 für 5 Minuten)
|
||||
#[arg(long, value_name = "SECS")]
|
||||
idle_timeout: Option<u64>,
|
||||
|
||||
/// Verhindert das automatische Sperren beim Sperren des Windows-Bildschirms (Win + L)
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_screen_lock: bool,
|
||||
|
||||
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_anti_leak: bool,
|
||||
},
|
||||
|
||||
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
||||
@@ -49,6 +107,93 @@ enum Commands {
|
||||
#[arg(short, long)]
|
||||
drive: String,
|
||||
},
|
||||
|
||||
/// Ändert das Master-Passwort eines bestehenden Sanctum-Containers
|
||||
Passwd {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort)
|
||||
#[arg(long)]
|
||||
recovery_key: Option<String>,
|
||||
},
|
||||
|
||||
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
|
||||
Backup {
|
||||
/// Pfad zur Quelldatei (.sanctum Containerdatei)
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Pfad zur Ziel-Backup-Datei (.sanctum.bak)
|
||||
#[arg(short, long)]
|
||||
output: PathBuf,
|
||||
},
|
||||
|
||||
/// Stellt einen Container aus einem Backup wieder her (inklusive B-Tree Integritätsprüfung)
|
||||
Restore {
|
||||
/// Pfad zur Backup-Datei (.sanctum.bak)
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Pfad zur Ziel-Containerdatei (.sanctum)
|
||||
#[arg(short, long)]
|
||||
output: PathBuf,
|
||||
},
|
||||
|
||||
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
|
||||
BackupHeader {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Optionaler Ausgabepfad (Standard: <CONTAINER>.hdr)
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Stellt den Container-Header aus einem Backup oder via 24-Wort Notfallschlüssel wieder her
|
||||
RestoreHeader {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Pfad zur Header-Backup-Datei (.sanctum.hdr)
|
||||
#[arg(long)]
|
||||
header_file: Option<PathBuf>,
|
||||
|
||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
||||
#[arg(long)]
|
||||
recovery_key: Option<String>,
|
||||
|
||||
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
slot: u32,
|
||||
},
|
||||
|
||||
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
|
||||
RecoveryKey {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
/// Führt eine Integritätsprüfung (FSCK) und Bitrot-Erkennung auf dem Container durch
|
||||
Verify {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Vollständige kryptografische AEAD-Prüfung aller Chunks
|
||||
#[arg(long, default_value_t = true)]
|
||||
full: bool,
|
||||
},
|
||||
|
||||
/// Registriert .sanctum Containerdateien im Windows Explorer (Doppelklick & Kontextmenü, keine Adminrechte)
|
||||
Register,
|
||||
|
||||
/// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry
|
||||
Unregister,
|
||||
}
|
||||
|
||||
fn parse_drive_letter(s: &str) -> Result<char> {
|
||||
@@ -65,7 +210,30 @@ fn parse_drive_letter(s: &str) -> Result<char> {
|
||||
Ok(ch.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
fn handle_init(container_path: &Path) -> Result<()> {
|
||||
pub fn parse_size_string(s: &str) -> Result<u64> {
|
||||
let trimmed = s.trim().to_uppercase();
|
||||
if let Some(num_str) = trimmed.strip_suffix("GB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
|
||||
Ok(n * 1024 * 1024 * 1024)
|
||||
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
|
||||
Ok(n * 1024 * 1024)
|
||||
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
|
||||
Ok(n * 1024)
|
||||
} else if let Ok(n) = trimmed.parse::<u64>() {
|
||||
Ok(n)
|
||||
} else {
|
||||
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_init(
|
||||
container_path: &Path,
|
||||
with_hidden: bool,
|
||||
carrier_name: &str,
|
||||
carrier_size_str: &str,
|
||||
) -> Result<()> {
|
||||
if container_path.exists() {
|
||||
bail!(
|
||||
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
|
||||
@@ -77,60 +245,590 @@ fn handle_init(container_path: &Path) -> Result<()> {
|
||||
println!("│ Sanctum — Neuen verschlüsselten Container erstellen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Zieldatei: {}", container_path.display());
|
||||
if with_hidden {
|
||||
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
|
||||
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
||||
let lower = carrier_name.to_lowercase();
|
||||
if lower.ends_with(".iso") || lower.ends_with(".vhd") || lower.ends_with(".img") || lower.ends_with(".zip") {
|
||||
println!(
|
||||
" [{}] Hinweis zur Plausible Deniability: Dateitypen wie '.iso' oder '.zip' besitzen ein starres Format. Da Carrier-Blöcke verschlüsselt sind (Entropie ~8.0), empfehlen sich unformatierte Typen wie '.dat', '.bin' oder '.enc' für maximale forensische Unauffälligkeit.",
|
||||
ui::yellow("Tipp")
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?;
|
||||
if with_hidden {
|
||||
let carrier_size_bytes = parse_size_string(carrier_size_str)?;
|
||||
if carrier_size_bytes < 2 * 1024 * 1024 {
|
||||
bail!("Trägerdateigröße muss mindestens 2 MB betragen (Block 0 Manifest + mindestens 1 Datenblock)");
|
||||
}
|
||||
|
||||
if password.trim().is_empty() {
|
||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
|
||||
let password_0 = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
if password_0.trim().is_empty() {
|
||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
let confirm_0 = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||
);
|
||||
if *password_0 != *confirm_0 {
|
||||
bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!");
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
|
||||
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
|
||||
let password_1 = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
if password_1.trim().is_empty() {
|
||||
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
|
||||
}
|
||||
if *password_1 == *password_0 {
|
||||
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
|
||||
}
|
||||
let confirm_1 = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||
);
|
||||
if *password_1 != *confirm_1 {
|
||||
bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!");
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...");
|
||||
let salt_0 = generate_salt();
|
||||
let kdf_params_0 = KdfParams::default();
|
||||
let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?;
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kdf_params_1 = KdfParams::default();
|
||||
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
|
||||
|
||||
ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG...");
|
||||
let dek_0 = generate_dek();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
||||
let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
||||
|
||||
ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params_0,
|
||||
&wrapped_dek_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params_1,
|
||||
&wrapped_dek_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)?;
|
||||
|
||||
db.checkpoint()?;
|
||||
|
||||
let phrase_0 = dek_to_mnemonic(&dek_0)?;
|
||||
let phrase_1 = dek_to_mnemonic(&dek_1)?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||
println!(" • Alibi-Datei: {} ({})", carrier_name, carrier_size_str);
|
||||
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
|
||||
println!(" • Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
|
||||
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
||||
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
|
||||
println!();
|
||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||
println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)");
|
||||
println!();
|
||||
|
||||
println!(" ┌─────────────────────────────────────────────────────────┐");
|
||||
println!(" │ NOTFALLSCHLÜSSEL — SLOT 0 (STANDARD / DECOY VAULT) │");
|
||||
println!(" └─────────────────────────────────────────────────────────┘");
|
||||
ui::print_recovery_phrase_card(&phrase_0);
|
||||
|
||||
println!(" ┌─────────────────────────────────────────────────────────┐");
|
||||
println!(" │ NOTFALLSCHLÜSSEL — SLOT 1 (HIDDEN VAULT) │");
|
||||
println!(" └─────────────────────────────────────────────────────────┘");
|
||||
ui::print_recovery_phrase_card(&phrase_1);
|
||||
} else {
|
||||
let password = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
|
||||
if password.trim().is_empty() {
|
||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
|
||||
let confirm_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||
);
|
||||
|
||||
if *password != *confirm_password {
|
||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams::default();
|
||||
let kek = derive_kek(&password, &salt, &kdf_params)
|
||||
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
|
||||
|
||||
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
|
||||
let dek = generate_dek();
|
||||
|
||||
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
|
||||
let (wrapped_dek, header_nonce, header_tag) =
|
||||
wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?;
|
||||
|
||||
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
|
||||
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None)
|
||||
.context("Fehler bei der Schema-Initialisierung")?;
|
||||
|
||||
db.checkpoint()
|
||||
.context("Fehler beim finalen WAL-Checkpoint")?;
|
||||
|
||||
let recovery_phrase =
|
||||
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
||||
println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt");
|
||||
println!();
|
||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||
println!();
|
||||
|
||||
ui::print_recovery_phrase_card(&recovery_phrase);
|
||||
}
|
||||
|
||||
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if password != confirm_password {
|
||||
fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Storage-Kompaktierung (Anti-Forensics) │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let freelist_before = db.freelist_count()
|
||||
.context("Konnte Freelist-Größe nicht ermitteln")?;
|
||||
|
||||
println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string()));
|
||||
|
||||
if freelist_before == 0 {
|
||||
println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig.");
|
||||
println!();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus...");
|
||||
let freed = db.incremental_vacuum(pages)
|
||||
.context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?;
|
||||
|
||||
ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch...");
|
||||
db.checkpoint()
|
||||
.context("Fehler beim finalen WAL-Checkpoint")?;
|
||||
|
||||
let freelist_after = db.freelist_count().unwrap_or(0);
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Container erfolgreich kompaktiert! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Freigegebene Seiten: {}", ui::green(&freed.to_string()));
|
||||
println!(" • Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string()));
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Master-Passwort ändern │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
|
||||
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
|
||||
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||
(d, 0, None, None)
|
||||
} else {
|
||||
let old_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||
.context("Fehler beim Einlesen des aktuellen Passworts")?,
|
||||
);
|
||||
|
||||
if old_password.trim().is_empty() {
|
||||
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
|
||||
let keys = meta
|
||||
.authenticate(&old_password)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||
let d = keys.dek().clone();
|
||||
let slot_id = keys.slot_id();
|
||||
(d, slot_id, keys.carrier_dek(), keys.carrier_node_id())
|
||||
};
|
||||
|
||||
println!();
|
||||
let new_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
||||
.context("Fehler beim Einlesen des neuen Passworts")?,
|
||||
);
|
||||
|
||||
if new_password.trim().is_empty() {
|
||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
|
||||
let confirm_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||
);
|
||||
|
||||
if *new_password != *confirm_password {
|
||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams::default();
|
||||
let kek = derive_kek(&password, &salt, &kdf_params)
|
||||
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
|
||||
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
|
||||
let new_salt = generate_salt();
|
||||
let new_params = KdfParams::default();
|
||||
let new_kek = derive_kek(&new_password, &new_salt, &new_params)
|
||||
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
||||
|
||||
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
|
||||
let dek = generate_dek();
|
||||
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
||||
let c_dek = carrier_dek.unwrap_or_else(generate_dek);
|
||||
let c_nid = carrier_node_id.unwrap_or(0);
|
||||
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
||||
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||
} else {
|
||||
let c_nid = carrier_node_id.unwrap_or(0);
|
||||
wrap_slot0_payload(&new_kek, &dek, c_nid)
|
||||
.context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||
};
|
||||
|
||||
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
|
||||
let (wrapped_dek, header_nonce, header_tag) =
|
||||
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
|
||||
|
||||
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||
.context("Fehler bei der Schema-Initialisierung")?;
|
||||
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
|
||||
db.update_slot_keys(
|
||||
target_slot_id,
|
||||
&new_salt,
|
||||
&new_params,
|
||||
&new_wrapped_dek,
|
||||
&new_nonce,
|
||||
&new_tag,
|
||||
)
|
||||
.context("Fehler beim Aktualisieren der Container-Metadaten")?;
|
||||
|
||||
db.checkpoint()
|
||||
.context("Fehler beim finalen WAL-Checkpoint")?;
|
||||
.context("Fehler beim WAL-Checkpoint nach Passwortänderung")?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
|
||||
println!("│ ✔ Master-Passwort erfolgreich geändert! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Format: Version 1 (Magic: SANCTUM\\0)");
|
||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||
println!(" • Cipher: AES-256-GCM (1-MB Chunks, AEAD Swap-Schutz)");
|
||||
println!(" • Vault Slot: {}", target_slot_id);
|
||||
println!(" • KDF: Argon2id mit frischem Salt");
|
||||
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
|
||||
println!();
|
||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display())));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Result<()> {
|
||||
let out = match output_path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => {
|
||||
let file_name = container_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy();
|
||||
container_path.with_file_name(format!("{file_name}.hdr"))
|
||||
}
|
||||
};
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Container-Header sichern │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!(" Backup-Ziel: {}", out.display());
|
||||
println!();
|
||||
|
||||
export_header_backup(container_path, &out)?;
|
||||
|
||||
println!("{}", ui::green("✔ Header-Backup erfolgreich exportiert!"));
|
||||
println!();
|
||||
println!(" Bewahren Sie diese Sicherungsdatei an einem sicheren Ort auf.");
|
||||
println!(" Sie enthält alle KDF-Parameter und den verschlüsselten Master-DEK.");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
if output_path.exists() {
|
||||
bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display());
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Online-Backup erstellen (Hot-Backup) │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!(" Backup-Ziel: {}", output_path.display());
|
||||
println!();
|
||||
|
||||
ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream...");
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API...");
|
||||
db.online_backup(output_path)
|
||||
.context("Fehler beim Erstellen des Online-Backups")?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Online-Backup erfolgreich abgeschlossen! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Backup-Datei: {}", output_path.display());
|
||||
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
|
||||
if !backup_path.exists() {
|
||||
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
||||
}
|
||||
if output_path.exists() {
|
||||
bail!("Zieldatei '{}' existiert bereits. Wiederherstellung abgebrochen, um Überschreiben zu verhindern.", output_path.display());
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Container aus Backup wiederherstellen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Backup-Quelle: {}", backup_path.display());
|
||||
println!(" Container-Ziel: {}", output_path.display());
|
||||
println!();
|
||||
|
||||
ui::step(1, 2, "📦", "Kopiere Backup-Seiten in Ziel-Container...");
|
||||
Database::restore_from_backup(backup_path, output_path)
|
||||
.context("Fehler bei der Container-Wiederherstellung aus dem Backup")?;
|
||||
|
||||
ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!");
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Container erfolgreich aus Backup wiederhergestellt! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Neuer Container: {}", output_path.display());
|
||||
println!(" • Integrität: Vollständig intakt & bereit zum Mounten");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_restore_header(
|
||||
container_path: &Path,
|
||||
header_file: Option<&Path>,
|
||||
recovery_key: Option<&str>,
|
||||
slot: u32,
|
||||
) -> Result<()> {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Container-Header wiederherstellen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
if let Some(hdr) = header_file {
|
||||
println!(" Verwende Backup-Datei: {}", hdr.display());
|
||||
ui::step(1, 2, "📦", "Lese und validiere Header-Backup...");
|
||||
restore_header_backup(container_path, hdr)?;
|
||||
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
|
||||
println!();
|
||||
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
||||
} else if let Some(key) = recovery_key {
|
||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
|
||||
let new_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
|
||||
if new_password.trim().is_empty() {
|
||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
|
||||
let confirm_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||
);
|
||||
|
||||
if *new_password != *confirm_password {
|
||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||
}
|
||||
|
||||
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
||||
restore_slot_from_recovery_key(container_path, key, &new_password, slot)?;
|
||||
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
|
||||
println!();
|
||||
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
||||
} else {
|
||||
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
|
||||
}
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — 24-Wort Notfallschlüssel anzeigen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let password = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
let keys = meta
|
||||
.authenticate(&password)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||
let dek = keys.dek().clone();
|
||||
let slot_id = keys.slot_id();
|
||||
|
||||
let phrase = dek_to_mnemonic(&dek)?;
|
||||
if slot_id == 1 {
|
||||
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
|
||||
}
|
||||
ui::print_recovery_phrase_card(&phrase);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Container-Integritätsprüfung (FSCK) │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let password = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
|
||||
let dek = if !password.trim().is_empty() {
|
||||
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
|
||||
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
|
||||
|
||||
match meta.authenticate(&password) {
|
||||
Some(keys) => {
|
||||
let slot_id = keys.slot_id();
|
||||
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
|
||||
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc));
|
||||
Some(keys.dek().clone())
|
||||
}
|
||||
None => {
|
||||
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", ui::dim("ℹ"));
|
||||
None
|
||||
};
|
||||
|
||||
let report = verify_container(container_path, dek.as_ref(), full)?;
|
||||
ui::print_verification_report(&report);
|
||||
|
||||
if !report.is_healthy() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -138,19 +836,76 @@ async fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Init { path } => {
|
||||
handle_init(&path)?;
|
||||
Commands::Init {
|
||||
path,
|
||||
with_hidden,
|
||||
carrier_name,
|
||||
carrier_size,
|
||||
} => {
|
||||
handle_init(&path, with_hidden, &carrier_name, &carrier_size)?;
|
||||
}
|
||||
Commands::Mount { path, drive, port } => {
|
||||
let drive_char = parse_drive_letter(&drive)?;
|
||||
let prompt_text = format!(
|
||||
"Master-Passwort für Container '{}' eingeben: ",
|
||||
path.display()
|
||||
);
|
||||
let password = rpassword::prompt_password(prompt_text)
|
||||
.context("Fehler beim Einlesen des Passworts")?;
|
||||
Commands::Compact { path, pages } => {
|
||||
handle_compact(&path, pages)?;
|
||||
}
|
||||
Commands::Backup { path, output } => {
|
||||
handle_backup(&path, &output)?;
|
||||
}
|
||||
Commands::Restore { path, output } => {
|
||||
handle_restore(&path, &output)?;
|
||||
}
|
||||
Commands::Mount {
|
||||
path,
|
||||
drive,
|
||||
port,
|
||||
recovery_key,
|
||||
no_open,
|
||||
no_tray,
|
||||
idle_timeout,
|
||||
no_screen_lock,
|
||||
no_anti_leak,
|
||||
} => {
|
||||
let drive_char = match drive {
|
||||
Some(ref d) => parse_drive_letter(d)?,
|
||||
None => {
|
||||
let auto_drive = sanctum::windows::find_next_available_drive()?;
|
||||
println!(
|
||||
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
|
||||
ui::cyan("ℹ"),
|
||||
auto_drive
|
||||
);
|
||||
auto_drive
|
||||
}
|
||||
};
|
||||
let auth = if let Some(key) = recovery_key {
|
||||
ContainerAuth::RecoveryKey(Zeroizing::new(key))
|
||||
} else {
|
||||
let prompt_text = format!(
|
||||
"Master-Passwort für Container '{}' eingeben: ",
|
||||
path.display()
|
||||
);
|
||||
let password = Zeroizing::new(
|
||||
rpassword::prompt_password(prompt_text)
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
);
|
||||
ContainerAuth::Password(password)
|
||||
};
|
||||
|
||||
mount_container(&path, drive_char, port, &password).await?;
|
||||
let open_explorer = !no_open;
|
||||
let enable_tray = !no_tray;
|
||||
let lock_on_screen_lock = !no_screen_lock;
|
||||
let anti_leak = !no_anti_leak;
|
||||
mount_container(
|
||||
&path,
|
||||
drive_char,
|
||||
port,
|
||||
auth,
|
||||
open_explorer,
|
||||
enable_tray,
|
||||
idle_timeout,
|
||||
lock_on_screen_lock,
|
||||
anti_leak,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Commands::Unmount { drive } => {
|
||||
let drive_char = parse_drive_letter(&drive)?;
|
||||
@@ -161,11 +916,52 @@ async fn run() -> Result<()> {
|
||||
println!("{}", ui::green("OK"));
|
||||
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
||||
}
|
||||
Commands::Passwd { path, recovery_key } => {
|
||||
handle_passwd(&path, recovery_key.as_deref())?;
|
||||
}
|
||||
Commands::BackupHeader { path, output } => {
|
||||
handle_backup_header(&path, output.as_deref())?;
|
||||
}
|
||||
Commands::RestoreHeader {
|
||||
path,
|
||||
header_file,
|
||||
recovery_key,
|
||||
slot,
|
||||
} => {
|
||||
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref(), slot)?;
|
||||
}
|
||||
Commands::RecoveryKey { path } => {
|
||||
handle_recovery_key(&path)?;
|
||||
}
|
||||
Commands::Verify { path, full } => {
|
||||
handle_verify(&path, full)?;
|
||||
}
|
||||
Commands::Register => {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Windows Explorer Integration einrichten │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
sanctum::windows::register_explorer_integration()?;
|
||||
println!(" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!", ui::green("✔"));
|
||||
println!(" • Doppelklick: Öffnet und bindet den Container direkt als Laufwerk ein");
|
||||
println!(" • Rechtsklick: Bietet Optionen für Integritätsprüfung (FSCK) und Header-Backup");
|
||||
println!();
|
||||
}
|
||||
Commands::Unregister => {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Windows Explorer Integration entfernen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
sanctum::windows::unregister_explorer_integration()?;
|
||||
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
|
||||
|
||||
+386
-68
@@ -2,6 +2,8 @@ use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use dav_server::{fakels::FakeLs, DavHandler};
|
||||
@@ -11,12 +13,20 @@ use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{derive_kek, unwrap_dek};
|
||||
use crate::crypto::mnemonic_to_dek;
|
||||
use crate::storage::Database;
|
||||
use crate::ui;
|
||||
use crate::vfs::SanctumFs;
|
||||
|
||||
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContainerAuth {
|
||||
Password(Zeroizing<String>),
|
||||
RecoveryKey(Zeroizing<String>),
|
||||
}
|
||||
|
||||
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
|
||||
pub fn format_drive(drive_letter: char) -> String {
|
||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||
@@ -45,23 +55,54 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/ /persistent:no` ein.
|
||||
fn run_net_use_mount(drive_str: &str, port: u16) -> Result<()> {
|
||||
let url = format!("http://127.0.0.1:{}/", port);
|
||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein.
|
||||
fn run_net_use_mount(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
||||
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
|
||||
|
||||
let output = Command::new("net")
|
||||
let mut output = Command::new("net")
|
||||
.args(["use", drive_str, &url, "/persistent:no"])
|
||||
.output()
|
||||
.context("Fehler beim Ausführen des Befehls 'net use'")?;
|
||||
|
||||
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let is_already_in_use = stderr.contains("85")
|
||||
|| stderr.contains("bereits verwendet")
|
||||
|| stderr.contains("already in use")
|
||||
|| stdout.contains("85")
|
||||
|| stdout.contains("bereits verwendet")
|
||||
|| stdout.contains("already in use");
|
||||
|
||||
if is_already_in_use {
|
||||
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
|
||||
let _ = Command::new("net")
|
||||
.args(["use", drive_str, "/delete", "/y"])
|
||||
.output();
|
||||
|
||||
// Zweiter Versuch nach automatischer Bereinigung
|
||||
output = Command::new("net")
|
||||
.args(["use", drive_str, &url, "/persistent:no"])
|
||||
.output()
|
||||
.context("Fehler beim erneuten Ausführen des Befehls 'net use'")?;
|
||||
}
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
|
||||
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
bail!(
|
||||
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}",
|
||||
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
|
||||
drive_str,
|
||||
stdout,
|
||||
stderr
|
||||
stderr,
|
||||
webclient_hint
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +114,12 @@ pub async fn mount_container(
|
||||
container_path: &Path,
|
||||
drive_letter: char,
|
||||
requested_port: Option<u16>,
|
||||
password: &str,
|
||||
auth: ContainerAuth,
|
||||
open_explorer: bool,
|
||||
enable_tray: bool,
|
||||
idle_timeout: Option<u64>,
|
||||
lock_on_screen_lock: bool,
|
||||
anti_leak: bool,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
|
||||
@@ -93,22 +139,58 @@ pub async fn mount_container(
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
|
||||
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params)
|
||||
.context("Schlüsselableitung fehlgeschlagen")?;
|
||||
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
|
||||
ContainerAuth::Password(ref password) => {
|
||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
||||
match meta.authenticate(password) {
|
||||
Some(keys) => {
|
||||
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
||||
let vault_id = keys.slot_id();
|
||||
let ver = keys.version();
|
||||
let c_dek = keys.carrier_dek();
|
||||
let c_nid = keys.carrier_node_id();
|
||||
(keys.0, c_dek, c_nid, ver, vault_id)
|
||||
}
|
||||
None => {
|
||||
bail!("Ungültiges Master-Passwort oder Container beschädigt");
|
||||
}
|
||||
}
|
||||
}
|
||||
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
||||
let dek = mnemonic_to_dek(phrase)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||
|
||||
ui::step(3, 4, "🔓", "Entschlüssele DEK via AES-256-GCM...");
|
||||
let dek = unwrap_dek(
|
||||
&kek,
|
||||
&meta.wrapped_dek,
|
||||
&meta.header_nonce,
|
||||
&meta.header_tag,
|
||||
)
|
||||
.context("Ungültiges Master-Passwort oder Container beschädigt")?;
|
||||
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
|
||||
let is_hidden = {
|
||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
||||
!children.is_empty()
|
||||
};
|
||||
let vault_id = if is_hidden { 1 } else { 0 };
|
||||
(dek, None, None, meta.version, vault_id)
|
||||
}
|
||||
};
|
||||
|
||||
// WebDAV Filesystem und Handler konfigurieren
|
||||
let fs = SanctumFs::new(db.clone(), dek);
|
||||
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
|
||||
let session_token = hex::encode(token_bytes);
|
||||
let token_path_prefix = format!("/{}", session_token);
|
||||
|
||||
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
|
||||
let fs = SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
dek,
|
||||
carrier_dek,
|
||||
carrier_node_id,
|
||||
version,
|
||||
anti_leak,
|
||||
vault_id,
|
||||
);
|
||||
let last_activity = fs.last_activity();
|
||||
let dav_server = DavHandler::builder()
|
||||
.strip_prefix(token_path_prefix.clone())
|
||||
.filesystem(Box::new(fs))
|
||||
.locksystem(FakeLs::new())
|
||||
.build_handler();
|
||||
@@ -140,54 +222,100 @@ pub async fn mount_container(
|
||||
),
|
||||
);
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
// Hyper HTTP Server Loop im Hintergrund starten
|
||||
let server_dav = dav_server.clone();
|
||||
let server_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = listener.accept() => {
|
||||
let (stream, _) = match res {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
warn!("Verbindungsfehler im TCP-Listener: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let io = TokioIo::new(stream);
|
||||
let handler = server_dav.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |req| {
|
||||
let h = handler.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(h.handle(req).await)
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
|
||||
// Client-Disconnects im Explorer sind normal
|
||||
debug!("HTTP-Verbindungsende: {:?}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||
listener,
|
||||
dav_server,
|
||||
token_path_prefix.clone(),
|
||||
shutdown_rx,
|
||||
));
|
||||
|
||||
// Netzlaufwerk einbinden
|
||||
if let Err(e) = run_net_use_mount(&drive_str, bound_port) {
|
||||
if let Err(e) = run_net_use_mount(&drive_str, bound_port, &session_token) {
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Optional automatisch im Windows Explorer öffnen
|
||||
if open_explorer {
|
||||
let _ = crate::windows::open_in_explorer(drive_letter);
|
||||
}
|
||||
|
||||
// System-Tray Initialisierung
|
||||
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _tray = if enable_tray {
|
||||
let icon_source = crate::windows::get_default_system_icon()
|
||||
.unwrap_or(tray_item::IconSource::Resource(""));
|
||||
let title = format!("Sanctum ({drive_str})");
|
||||
match tray_item::TrayItem::new(&title, icon_source) {
|
||||
Ok(mut tray) => {
|
||||
let container_name = container_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let _ = tray.add_label(&format!("Sanctum: {drive_str} ({container_name})"));
|
||||
let dl = drive_letter;
|
||||
let _ = tray.add_menu_item("Im Explorer öffnen", move || {
|
||||
let _ = crate::windows::open_in_explorer(dl);
|
||||
});
|
||||
let s_tx = tray_shutdown_tx.clone();
|
||||
let _ = tray.add_menu_item("Trennen & Beenden", move || {
|
||||
let _ = s_tx.blocking_send(());
|
||||
});
|
||||
Some(tray)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("System-Tray Icon konnte nicht erstellt werden: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Inaktivitäts-Timer (Auto-Lock)
|
||||
let (idle_shutdown_tx, mut idle_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
if let Some(timeout_secs) = idle_timeout {
|
||||
if timeout_secs > 0 {
|
||||
let last_act = last_activity.clone();
|
||||
let idle_tx = idle_shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let last = last_act.load(Ordering::Relaxed);
|
||||
if now.saturating_sub(last) >= timeout_secs {
|
||||
let _ = idle_tx.send(()).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Windows-Sitzungssperre (Win + L Auto-Lock)
|
||||
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
let _session_monitor = if lock_on_screen_lock {
|
||||
match crate::windows::start_session_lock_monitor(session_lock_tx) {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(e) => {
|
||||
warn!("Konnte Windows Session-Lock Monitor nicht aktivieren: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
|
||||
@@ -195,18 +323,54 @@ pub async fn mount_container(
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
|
||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port);
|
||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
|
||||
if vault_id == 1 {
|
||||
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
|
||||
}
|
||||
if let Some(secs) = idle_timeout {
|
||||
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
|
||||
}
|
||||
if lock_on_screen_lock {
|
||||
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
|
||||
}
|
||||
if anti_leak {
|
||||
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
|
||||
}
|
||||
if enable_tray {
|
||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
||||
}
|
||||
println!();
|
||||
println!(" [{}] Drücke [Ctrl+C] zum sicheren Trennen und Schließen.", ui::yellow("Tipp"));
|
||||
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
|
||||
println!();
|
||||
|
||||
// Warten auf Strg+C
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.context("Fehler beim Registrieren des Ctrl+C Signalhandlers")?;
|
||||
// Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
|
||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
|
||||
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
let _ = res;
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
_ = console_close_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||
}
|
||||
_ = tray_shutdown_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
_ = session_lock_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
_ = idle_shutdown_rx.recv() => {
|
||||
println!();
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
@@ -221,6 +385,18 @@ pub async fn mount_container(
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
|
||||
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
|
||||
if let Ok(freelist) = db.freelist_count() {
|
||||
if freelist > 0 {
|
||||
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
match db.incremental_vacuum(None) {
|
||||
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
|
||||
Err(e) => println!("{}", ui::yellow(&format!("Warnung ({e})"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite WAL Checkpoint erzwingen
|
||||
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
@@ -236,3 +412,145 @@ pub async fn mount_container(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Überprüft, ob ein Host-Header auf Loopback (127.0.0.1, localhost, [::1]) zeigt (Anti-DNS-Rebinding).
|
||||
pub fn is_loopback_host(host_str: &str) -> bool {
|
||||
let host_lower = host_str.to_ascii_lowercase();
|
||||
host_lower.starts_with("127.0.0.1")
|
||||
|| host_lower.starts_with("localhost")
|
||||
|| host_lower.starts_with("[::1]")
|
||||
}
|
||||
|
||||
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
|
||||
const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
|
||||
|
||||
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
|
||||
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
|
||||
pub async fn serve_webdav_loop(
|
||||
listener: TcpListener,
|
||||
dav_server: DavHandler,
|
||||
token_path_prefix: String,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = listener.accept() => {
|
||||
let (stream, _) = match res {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
warn!("Verbindungsfehler im TCP-Listener: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// RT-01: Schutz gegen Connection-Starvation / Socket-Flooding (CWE-400)
|
||||
let permit = match conn_semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"WebDAV-Verbindungslimit ({} aktive Verbindungen) erreicht: Wehre potenziellen Connection-Starvation-Angriff ab.",
|
||||
MAX_CONCURRENT_DAV_CONNECTIONS
|
||||
);
|
||||
drop(stream);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let io = TokioIo::new(stream);
|
||||
let handler = dav_server.clone();
|
||||
let expected_prefix = token_path_prefix.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||
|
||||
let service = service_fn(move |req| {
|
||||
let h = handler.clone();
|
||||
let prefix = expected_prefix.clone();
|
||||
async move {
|
||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||
let host_valid = match req.headers().get(hyper::header::HOST) {
|
||||
Some(host_val) => match host_val.to_str() {
|
||||
Ok(host_str) => is_loopback_host(host_str),
|
||||
Err(_) => false,
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !host_valid {
|
||||
warn!(
|
||||
"Abgewiesener Zugriff: Fehlender, ungültiger oder externer Host-Header ({:?})",
|
||||
req.headers().get(hyper::header::HOST)
|
||||
);
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
|
||||
let path = req.uri().path();
|
||||
if !path.starts_with(&prefix) {
|
||||
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
Ok::<_, Infallible>(h.handle(req).await)
|
||||
}
|
||||
});
|
||||
|
||||
// RT-01: Header-Read-Timeout & TokioTimer zur aktiven Abwehr von Slowloris-Hanging-Sockets
|
||||
let mut builder = http1::Builder::new();
|
||||
builder.timer(hyper_util::rt::TokioTimer::new());
|
||||
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
|
||||
|
||||
if let Err(err) = builder.serve_connection(io, service).await {
|
||||
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
|
||||
debug!("HTTP-Verbindungsende: {:?}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
debug!("WebDAV-Server-Task empfängt Shutdown-Signal.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_loopback_host_validation() {
|
||||
assert!(is_loopback_host("127.0.0.1"));
|
||||
assert!(is_loopback_host("127.0.0.1:8443"));
|
||||
assert!(is_loopback_host("localhost"));
|
||||
assert!(is_loopback_host("localhost:8443"));
|
||||
assert!(is_loopback_host("[::1]"));
|
||||
assert!(is_loopback_host("[::1]:8443"));
|
||||
|
||||
// Abweisung externer Hosts oder DNS-Rebinding-Attacken
|
||||
assert!(!is_loopback_host("evil.com"));
|
||||
assert!(!is_loopback_host("attacker.local"));
|
||||
assert!(!is_loopback_host("192.168.1.50"));
|
||||
assert!(!is_loopback_host("10.0.0.1"));
|
||||
assert!(!is_loopback_host(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_drive() {
|
||||
assert_eq!(format_drive('s'), "S:");
|
||||
assert_eq!(format_drive('Z'), "Z:");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
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::{
|
||||
derive_kek, generate_dummy_slot, mnemonic_to_dek, 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() {
|
||||
for s in &self.slots {
|
||||
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")?;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
} 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(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
|
||||
if new_password.trim().is_empty() {
|
||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
||||
}
|
||||
|
||||
// 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 DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
|
||||
let dek_0_bytes = [0u8; 32];
|
||||
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 Plausible Deniability
|
||||
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)
|
||||
}
|
||||
|
||||
#[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: 1024,
|
||||
time_cost: 1,
|
||||
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: 1024,
|
||||
time_cost: 1,
|
||||
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);
|
||||
}
|
||||
}
|
||||
+1250
-108
File diff suppressed because it is too large
Load Diff
@@ -92,8 +92,114 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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!();
|
||||
}
|
||||
|
||||
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
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};
|
||||
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 {
|
||||
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
|
||||
};
|
||||
|
||||
// 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,
|
||||
) {
|
||||
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: 1024,
|
||||
time_cost: 1,
|
||||
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();
|
||||
|
||||
// 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).unwrap();
|
||||
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
|
||||
|
||||
let chunk1_data = b"Additional payload data bytes";
|
||||
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
|
||||
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
|
||||
|
||||
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 (Plausible Deniability) + 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: 1024,
|
||||
time_cost: 1,
|
||||
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();
|
||||
|
||||
let file = db.create_node(1, "document.pdf", false).unwrap();
|
||||
let chunk_data = b"Vital documents that must not be corrupted";
|
||||
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
|
||||
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
|
||||
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);
|
||||
}
|
||||
}
|
||||
+475
-76
@@ -1,5 +1,6 @@
|
||||
use std::fmt::Debug;
|
||||
use std::io::SeekFrom;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -13,11 +14,37 @@ use dav_server::{
|
||||
};
|
||||
use futures_util::stream;
|
||||
use tracing::{debug, error, warn};
|
||||
use zeroize::Zeroizing;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::carrier::CarrierFs;
|
||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||
use crate::storage::{Database, NodeRecord};
|
||||
|
||||
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache-
|
||||
/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig
|
||||
/// im Container blockiert und verborgen werden (Anti-Leak Shield).
|
||||
pub fn is_leak_file(filename: &str) -> bool {
|
||||
let lower = filename.trim().to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"thumbs.db"
|
||||
| "ehthumbs.db"
|
||||
| "ehthumbs_vista.db"
|
||||
| "desktop.ini"
|
||||
| "folder.jpg"
|
||||
| "albumartsmall.jpg"
|
||||
| "autorun.inf"
|
||||
| ".ds_store" => true,
|
||||
_ => {
|
||||
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
|
||||
{
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadaten
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,6 +113,8 @@ pub struct SanctumFile {
|
||||
meta: SanctumMetaData,
|
||||
// (chunk_index, decrypted_payload, is_dirty)
|
||||
cached_chunk: Option<(u32, Vec<u8>, bool)>,
|
||||
format_version: u32,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl Debug for SanctumFile {
|
||||
@@ -94,6 +123,7 @@ impl Debug for SanctumFile {
|
||||
.field("node_id", &self.node_id)
|
||||
.field("file_size", &self.file_size)
|
||||
.field("cursor", &self.cursor)
|
||||
.field("format_version", &self.format_version)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -103,6 +133,9 @@ impl SanctumFile {
|
||||
node: NodeRecord,
|
||||
db: Database,
|
||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
||||
format_version: u32,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
append: bool,
|
||||
) -> Self {
|
||||
let meta = SanctumMetaData {
|
||||
is_dir: node.is_dir,
|
||||
@@ -111,22 +144,35 @@ impl SanctumFile {
|
||||
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
|
||||
};
|
||||
|
||||
let cursor = if append { node.size } else { 0 };
|
||||
|
||||
Self {
|
||||
node_id: node.id,
|
||||
file_size: node.size,
|
||||
cursor: 0,
|
||||
cursor,
|
||||
db,
|
||||
dek,
|
||||
meta,
|
||||
cached_chunk: None,
|
||||
format_version,
|
||||
last_activity,
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&self) {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
self.last_activity.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück.
|
||||
fn flush_cached_chunk(&mut self) -> Result<(), FsError> {
|
||||
if let Some((idx, ref data, true)) = self.cached_chunk {
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&self.dek, self.node_id, idx, data).map_err(|e| {
|
||||
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
|
||||
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
@@ -154,6 +200,9 @@ impl SanctumFile {
|
||||
|
||||
if !is_current {
|
||||
self.flush_cached_chunk()?;
|
||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||
data.zeroize();
|
||||
}
|
||||
|
||||
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
|
||||
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
|
||||
@@ -166,6 +215,7 @@ impl SanctumFile {
|
||||
&record.ciphertext,
|
||||
&record.nonce,
|
||||
&record.tag,
|
||||
self.format_version,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
||||
@@ -189,6 +239,9 @@ impl Drop for SanctumFile {
|
||||
if let Err(e) = self.flush_cached_chunk() {
|
||||
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
|
||||
}
|
||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||
data.zeroize();
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@@ -205,6 +258,7 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> {
|
||||
self.touch();
|
||||
Box::pin(async move {
|
||||
if self.cursor >= self.file_size || count == 0 {
|
||||
return Ok(Bytes::new());
|
||||
@@ -247,6 +301,7 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
||||
self.touch();
|
||||
Box::pin(async move {
|
||||
let mut src = &buf[..];
|
||||
|
||||
@@ -295,6 +350,7 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> {
|
||||
self.touch();
|
||||
Box::pin(async move {
|
||||
let new_cursor = match pos {
|
||||
SeekFrom::Start(offset) => offset as i64,
|
||||
@@ -312,6 +368,7 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> FsFuture<'_, ()> {
|
||||
self.touch();
|
||||
Box::pin(async move {
|
||||
self.flush_cached_chunk()?;
|
||||
let now = SystemTime::now()
|
||||
@@ -339,13 +396,117 @@ impl DavFile for SanctumFile {
|
||||
pub struct SanctumFs {
|
||||
db: Database,
|
||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
||||
#[allow(dead_code)]
|
||||
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
|
||||
carrier_node_id: Option<i64>,
|
||||
carrier_fs: Option<CarrierFs>,
|
||||
format_version: u32,
|
||||
anti_leak: bool,
|
||||
vault_id: u32,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl SanctumFs {
|
||||
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>) -> Self {
|
||||
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self {
|
||||
Self::with_vault(db, dek, format_version, true, 0)
|
||||
}
|
||||
|
||||
pub fn with_options(
|
||||
db: Database,
|
||||
dek: Zeroizing<[u8; 32]>,
|
||||
format_version: u32,
|
||||
anti_leak: bool,
|
||||
) -> Self {
|
||||
Self::with_vault(db, dek, format_version, anti_leak, 0)
|
||||
}
|
||||
|
||||
pub fn with_vault(
|
||||
db: Database,
|
||||
dek: Zeroizing<[u8; 32]>,
|
||||
format_version: u32,
|
||||
anti_leak: bool,
|
||||
vault_id: u32,
|
||||
) -> Self {
|
||||
Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id)
|
||||
}
|
||||
|
||||
pub fn with_carrier(
|
||||
db: Database,
|
||||
dek: Zeroizing<[u8; 32]>,
|
||||
carrier_dek: Option<Zeroizing<[u8; 32]>>,
|
||||
carrier_node_id: Option<i64>,
|
||||
format_version: u32,
|
||||
anti_leak: bool,
|
||||
vault_id: u32,
|
||||
) -> Self {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let dek_arc = Arc::new(dek);
|
||||
let carrier_dek_arc = carrier_dek.map(Arc::new);
|
||||
|
||||
let carrier_fs = if vault_id == 1 {
|
||||
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
|
||||
match CarrierFs::load(
|
||||
db.clone(),
|
||||
c_nid,
|
||||
c_dek.clone(),
|
||||
dek_arc.clone(),
|
||||
format_version,
|
||||
anti_leak,
|
||||
) {
|
||||
Ok(cfs) => Some(cfs),
|
||||
Err(e) => {
|
||||
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
db,
|
||||
dek: Arc::new(dek),
|
||||
dek: dek_arc,
|
||||
carrier_dek: carrier_dek_arc,
|
||||
carrier_node_id,
|
||||
carrier_fs,
|
||||
format_version,
|
||||
anti_leak,
|
||||
vault_id,
|
||||
last_activity: Arc::new(AtomicU64::new(now)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vault_id(&self) -> u32 {
|
||||
self.vault_id
|
||||
}
|
||||
|
||||
pub fn last_activity(&self) -> Arc<AtomicU64> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
cfs.last_activity()
|
||||
} else {
|
||||
self.last_activity.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_anti_leak_enabled(&self) -> bool {
|
||||
self.anti_leak
|
||||
}
|
||||
|
||||
pub fn touch(&self) {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
cfs.touch();
|
||||
} else {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
self.last_activity.store(now, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +521,33 @@ impl SanctumFs {
|
||||
None => ("", trimmed),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
|
||||
self.db
|
||||
.resolve_path_in_vault(path, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
}
|
||||
|
||||
fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>, FsError> {
|
||||
self.db
|
||||
.list_children_in_vault(parent_id, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
}
|
||||
|
||||
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
|
||||
self.db
|
||||
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
||||
.map_err(|e| {
|
||||
error!("Fehler beim Erstellen des Knotens '{}': {e}", name);
|
||||
FsError::GeneralFailure
|
||||
})
|
||||
}
|
||||
|
||||
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
|
||||
self.db
|
||||
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
||||
.map_err(|_| FsError::GeneralFailure)
|
||||
}
|
||||
}
|
||||
|
||||
impl DavFileSystem for SanctumFs {
|
||||
@@ -368,17 +556,42 @@ impl DavFileSystem for SanctumFs {
|
||||
path: &'a DavPath,
|
||||
options: OpenOptions,
|
||||
) -> FsFuture<'a, Box<dyn DavFile>> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.open(path, options);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
|
||||
|
||||
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
|
||||
if self.anti_leak && is_leak_file(file_name) {
|
||||
if options.create
|
||||
|| options.create_new
|
||||
|| options.write
|
||||
|| options.append
|
||||
|| options.truncate
|
||||
{
|
||||
debug!(
|
||||
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
|
||||
file_name
|
||||
);
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
self.touch();
|
||||
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
|
||||
|
||||
let existing_node = self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
let existing_node = self.resolve_path(&path_str)?;
|
||||
|
||||
let node = match existing_node {
|
||||
Some(n) => {
|
||||
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
|
||||
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
if n.is_dir && (options.write || options.append) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
@@ -409,30 +622,29 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
None => {
|
||||
if options.create || options.create_new {
|
||||
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
|
||||
let parent = self
|
||||
.db
|
||||
.resolve_path(parent_path)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
self.db
|
||||
.create_node(parent.id, file_name, false)
|
||||
.map_err(|e| {
|
||||
error!("Fehler beim Erstellen der Datei: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?
|
||||
self.create_node(parent.id, file_name, false)?
|
||||
} else {
|
||||
return Err(FsError::NotFound);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let file = SanctumFile::new(node, self.db.clone(), self.dek.clone());
|
||||
let file = SanctumFile::new(
|
||||
node,
|
||||
self.db.clone(),
|
||||
self.dek.clone(),
|
||||
self.format_version,
|
||||
self.last_activity.clone(),
|
||||
options.append,
|
||||
);
|
||||
Ok(Box::new(file) as Box<dyn DavFile>)
|
||||
})
|
||||
}
|
||||
@@ -440,27 +652,27 @@ impl DavFileSystem for SanctumFs {
|
||||
fn read_dir<'a>(
|
||||
&'a self,
|
||||
path: &'a DavPath,
|
||||
_meta: ReadDirMeta,
|
||||
meta: ReadDirMeta,
|
||||
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.read_dir(path, meta);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
let children = self
|
||||
.db
|
||||
.list_children(node.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
let children = self.list_children(node.id)?;
|
||||
|
||||
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
|
||||
.into_iter()
|
||||
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
|
||||
.map(|child| {
|
||||
Ok(Box::new(SanctumDirEntry {
|
||||
name: child.name,
|
||||
@@ -479,12 +691,14 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.metadata(path);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
let meta = SanctumMetaData {
|
||||
@@ -499,53 +713,61 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.symlink_metadata(path);
|
||||
}
|
||||
self.metadata(path)
|
||||
}
|
||||
|
||||
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.create_dir(path);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
if self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.is_some()
|
||||
{
|
||||
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
|
||||
|
||||
if self.anti_leak && is_leak_file(dir_name) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
if self.resolve_path(&path_str)?.is_some() {
|
||||
return Err(FsError::Exists);
|
||||
}
|
||||
|
||||
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
|
||||
let parent = self
|
||||
.db
|
||||
.resolve_path(parent_path)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
self.db
|
||||
.create_node(parent.id, dir_name, true)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
self.create_node(parent.id, dir_name, true)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.remove_dir(path);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
if node.id == 1 {
|
||||
let root_id = Database::get_root_node_id_for_vault(self.vault_id);
|
||||
if node.id == root_id {
|
||||
// Root-Verzeichnis darf nicht gelöscht werden
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
@@ -559,18 +781,26 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.remove_file(path);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&path_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
// Schutz der Trägerdatei im Decoy Vault: Löschen verboten!
|
||||
if self.carrier_node_id == Some(node.id) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
self.db
|
||||
.delete_node(node.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
@@ -584,21 +814,32 @@ impl DavFileSystem for SanctumFs {
|
||||
from: &'a DavPath,
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.rename(from, to);
|
||||
}
|
||||
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&from_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
|
||||
if self.carrier_node_id == Some(node.id) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
||||
|
||||
if self.anti_leak && is_leak_file(to_name) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
let to_parent = self
|
||||
.db
|
||||
.resolve_path(to_parent_path)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(to_parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !to_parent.is_dir {
|
||||
@@ -606,11 +847,7 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
// Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen
|
||||
if let Some(dest) = self
|
||||
.db
|
||||
.resolve_path(&to_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
{
|
||||
if let Some(dest) = self.resolve_path(&to_str)? {
|
||||
if dest.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
@@ -619,9 +856,7 @@ impl DavFileSystem for SanctumFs {
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
}
|
||||
|
||||
self.db
|
||||
.rename_node(node.id, to_parent.id, to_name)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
self.rename_node(node.id, to_parent.id, to_name)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -633,13 +868,12 @@ impl DavFileSystem for SanctumFs {
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.db
|
||||
.resolve_path(&from_str)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
@@ -647,16 +881,16 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
||||
|
||||
if self.anti_leak && is_leak_file(to_name) {
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
let to_parent = self
|
||||
.db
|
||||
.resolve_path(to_parent_path)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
.resolve_path(to_parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
|
||||
let dest_node = self
|
||||
.db
|
||||
.create_node(to_parent.id, to_name, false)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
let dest_node = self.create_node(to_parent.id, to_name, false)?;
|
||||
|
||||
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
|
||||
let total_chunks = if node.size == 0 {
|
||||
@@ -678,11 +912,12 @@ impl DavFileSystem for SanctumFs {
|
||||
&record.ciphertext,
|
||||
&record.nonce,
|
||||
&record.tag,
|
||||
self.format_version,
|
||||
)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
let (new_ct, new_nonce, new_tag) =
|
||||
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext)
|
||||
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.db
|
||||
@@ -711,3 +946,167 @@ impl DavFileSystem for SanctumFs {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
|
||||
use dav_server::fs::OpenOptions;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
#[test]
|
||||
fn test_is_leak_file() {
|
||||
assert!(is_leak_file("Thumbs.db"));
|
||||
assert!(is_leak_file("thumbs.db"));
|
||||
assert!(is_leak_file("THUMBS.DB"));
|
||||
assert!(is_leak_file("ehthumbs.db"));
|
||||
assert!(is_leak_file("ehthumbs_vista.db"));
|
||||
assert!(is_leak_file("desktop.ini"));
|
||||
assert!(is_leak_file("Desktop.ini"));
|
||||
assert!(is_leak_file("Folder.jpg"));
|
||||
assert!(is_leak_file("albumartsmall.jpg"));
|
||||
assert!(is_leak_file("AlbumArt_{12345}_Large.jpg"));
|
||||
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
|
||||
assert!(is_leak_file("autorun.inf"));
|
||||
assert!(is_leak_file(".ds_store"));
|
||||
|
||||
// Harmlos:
|
||||
assert!(!is_leak_file("secret.txt"));
|
||||
assert!(!is_leak_file("passwords.kdbx"));
|
||||
assert!(!is_leak_file("my_folder.jpg.txt"));
|
||||
assert!(!is_leak_file("desktop_notes.ini.bak"));
|
||||
}
|
||||
|
||||
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {
|
||||
let temp_dir = tempfile_placeholder::TempDir::new();
|
||||
let db_path = temp_dir.path().join("test_vfs.sanctum");
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek("testpwd", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||
.unwrap();
|
||||
|
||||
let fs = SanctumFs::with_options(db, dek, FORMAT_VERSION, anti_leak);
|
||||
(fs, temp_dir)
|
||||
}
|
||||
|
||||
mod tempfile_placeholder {
|
||||
use std::path::{Path, PathBuf};
|
||||
pub struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
pub fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_anti_leak_blocks_creation() {
|
||||
let (fs, _dir) = create_test_fs(true);
|
||||
|
||||
let path = DavPath::new("/desktop.ini").unwrap();
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create_new = true;
|
||||
|
||||
// desktop.ini muss blockiert werden mit Forbidden
|
||||
let res = fs.open(&path, opts).await;
|
||||
assert!(matches!(res, Err(FsError::Forbidden)));
|
||||
|
||||
// create_dir mit Thumbs.db muss auch blockiert werden
|
||||
let dir_path = DavPath::new("/Thumbs.db").unwrap();
|
||||
let res_dir = fs.create_dir(&dir_path).await;
|
||||
assert!(matches!(res_dir, Err(FsError::Forbidden)));
|
||||
|
||||
// Normale Datei muss erlaubt sein
|
||||
let valid_path = DavPath::new("/notes.txt").unwrap();
|
||||
let mut valid_opts = OpenOptions::default();
|
||||
valid_opts.write = true;
|
||||
valid_opts.create_new = true;
|
||||
let res_valid = fs.open(&valid_path, valid_opts).await;
|
||||
assert!(res_valid.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_anti_leak_filters_read_dir() {
|
||||
let (fs_shielded, _dir) = create_test_fs(true);
|
||||
|
||||
// Erstelle eine normale Datei
|
||||
let normal_path = DavPath::new("/legit.txt").unwrap();
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create_new = true;
|
||||
let res = fs_shielded.open(&normal_path, opts).await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Erzwinge direkt in die DB eine Thumbs.db Datei
|
||||
fs_shielded.db.create_node(1, "Thumbs.db", false).unwrap();
|
||||
|
||||
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
|
||||
let root_path = DavPath::new("/").unwrap();
|
||||
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut names = Vec::new();
|
||||
while let Some(entry) = stream.next().await {
|
||||
let entry = entry.unwrap();
|
||||
names.push(String::from_utf8_lossy(&entry.name()).to_string());
|
||||
}
|
||||
|
||||
assert!(names.contains(&"legit.txt".to_string()));
|
||||
assert!(!names.contains(&"Thumbs.db".to_string()));
|
||||
|
||||
// Mit unshielded FS (anti_leak = false) muss Thumbs.db sichtbar sein
|
||||
let fs_unshielded = SanctumFs::with_options(
|
||||
fs_shielded.db.clone(),
|
||||
zeroize::Zeroizing::new([0u8; 32]),
|
||||
FORMAT_VERSION,
|
||||
false,
|
||||
);
|
||||
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut names_unshielded = Vec::new();
|
||||
while let Some(entry) = stream_unshielded.next().await {
|
||||
let entry = entry.unwrap();
|
||||
names_unshielded.push(String::from_utf8_lossy(&entry.name()).to_string());
|
||||
}
|
||||
assert!(names_unshielded.contains(&"Thumbs.db".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_vfs_activity_tracking() {
|
||||
let (fs, _dir) = create_test_fs(true);
|
||||
let act_arc = fs.last_activity();
|
||||
let initial_time = act_arc.load(Ordering::Relaxed);
|
||||
assert!(initial_time > 0);
|
||||
|
||||
// Manuell zurückdatieren
|
||||
act_arc.store(1000, Ordering::Relaxed);
|
||||
assert_eq!(act_arc.load(Ordering::Relaxed), 1000);
|
||||
|
||||
// Nach einem VFS-Zugriff muss die Zeit aktualisiert sein
|
||||
let path = DavPath::new("/test_activity.txt").unwrap();
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create_new = true;
|
||||
let _ = fs.open(&path, opts).await;
|
||||
|
||||
let new_time = act_arc.load(Ordering::Relaxed);
|
||||
assert!(new_time > 1000);
|
||||
}
|
||||
}
|
||||
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// 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 direkt im Windows Explorer.
|
||||
pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer (HKCU, 100% Userland, keine Adminrechte).
|
||||
pub fn register_explorer_integration() -> Result<()> {
|
||||
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",
|
||||
"",
|
||||
"Als Laufwerk einbinden",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\open\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" mount --path \"%1\""),
|
||||
),
|
||||
// 5. Kontextmenü-Aktion: Integritätsprüfung
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify",
|
||||
"",
|
||||
"Integrität prüfen (FSCK)",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" verify --path \"%1\""),
|
||||
),
|
||||
// 6. Kontextmenü-Aktion: Header sichern
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header",
|
||||
"",
|
||||
"Header sichern",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" backup-header --path \"%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(())
|
||||
}
|
||||
|
||||
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU).
|
||||
pub fn unregister_explorer_integration() -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub struct SessionLockGuard {
|
||||
#[cfg(windows)]
|
||||
hwnd: isize,
|
||||
#[cfg(windows)]
|
||||
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)]
|
||||
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
|
||||
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||
if let Some(dl) = *guard {
|
||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||
let _ = std::process::Command::new("net")
|
||||
.args(["use", &drive_str, "/delete", "/y"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_next_available_drive() {
|
||||
let drive = find_next_available_drive().expect("Find next drive");
|
||||
assert!(drive.is_ascii_alphabetic());
|
||||
assert!(drive >= 'D' && drive <= 'Z');
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bytes::Bytes;
|
||||
use dav_server::davpath::DavPath;
|
||||
use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
|
||||
wrap_slot1_payload, KdfParams, CHUNK_SIZE,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::verify::verify_container;
|
||||
use sanctum::vfs::SanctumFs;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
||||
let path = temp_db_path("carrier_accounting");
|
||||
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB)
|
||||
let carrier_name = "system_backup.dat";
|
||||
|
||||
let pass_decoy = "DecoyPassword2026!";
|
||||
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
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. Authentifizierung beider Passwörter
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy");
|
||||
assert_eq!(auth_decoy.slot_id(), 0);
|
||||
assert_eq!(**auth_decoy.dek(), *dek_0);
|
||||
assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id));
|
||||
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
assert_eq!(auth_hidden.slot_id(), 1);
|
||||
assert_eq!(**auth_hidden.dek(), *dek_1);
|
||||
assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0);
|
||||
assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id));
|
||||
|
||||
// 2. Decoy Mount: Schutz der Alibi-Datei (system_backup.dat)
|
||||
let decoy_fs = SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
auth_decoy.dek().clone(),
|
||||
auth_decoy.carrier_dek(),
|
||||
auth_decoy.carrier_node_id(),
|
||||
auth_decoy.version(),
|
||||
true,
|
||||
0,
|
||||
);
|
||||
|
||||
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
|
||||
|
||||
// Metadaten der Alibi-Datei im Decoy prüfen
|
||||
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
|
||||
assert_eq!(carrier_meta.len(), carrier_size_bytes);
|
||||
assert!(!carrier_meta.is_dir());
|
||||
|
||||
// Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden
|
||||
let write_opts = OpenOptions {
|
||||
write: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
|
||||
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
|
||||
);
|
||||
|
||||
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
|
||||
assert!(
|
||||
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
|
||||
"Alibi-Datei darf nicht gelöscht werden"
|
||||
);
|
||||
|
||||
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
|
||||
let new_name = DavPath::new("/renamed.iso").unwrap();
|
||||
assert!(
|
||||
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
|
||||
"Alibi-Datei darf nicht umbenannt werden"
|
||||
);
|
||||
|
||||
// Alibi-Datei KANN im Decoy-Mount gelesen werden
|
||||
let read_opts = OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
|
||||
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
|
||||
assert_eq!(first_mb.len(), CHUNK_SIZE);
|
||||
|
||||
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
|
||||
let hidden_fs = SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
auth_hidden.dek().clone(),
|
||||
auth_hidden.carrier_dek(),
|
||||
auth_hidden.carrier_node_id(),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
1,
|
||||
);
|
||||
|
||||
// Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer)
|
||||
let root_path = DavPath::new("/").unwrap();
|
||||
let mut stream = hidden_fs
|
||||
.read_dir(&root_path, ReadDirMeta::None)
|
||||
.await
|
||||
.expect("Read dir root");
|
||||
use futures_util::StreamExt;
|
||||
let mut entries = Vec::new();
|
||||
while let Some(item) = stream.next().await {
|
||||
entries.push(item.unwrap().name());
|
||||
}
|
||||
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
|
||||
|
||||
// Ordner erstellen
|
||||
let secret_dir = DavPath::new("/Classified").unwrap();
|
||||
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
|
||||
|
||||
// Datei im Ordner anlegen und schreiben
|
||||
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
|
||||
let create_opts = OpenOptions {
|
||||
create: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut secret_file = hidden_fs
|
||||
.open(&secret_file_path, create_opts)
|
||||
.await
|
||||
.expect("Create secret file");
|
||||
|
||||
let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault";
|
||||
secret_file
|
||||
.write_bytes(Bytes::from_static(secret_content))
|
||||
.await
|
||||
.expect("Write secret content");
|
||||
secret_file.flush().await.expect("Flush secret file");
|
||||
drop(secret_file);
|
||||
|
||||
// Datei lesen und verifizieren
|
||||
let read_opts = OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut read_handle = hidden_fs
|
||||
.open(&secret_file_path, read_opts)
|
||||
.await
|
||||
.expect("Open secret file for read");
|
||||
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
|
||||
assert_eq!(&read_data[..], secret_content);
|
||||
drop(read_handle);
|
||||
|
||||
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
|
||||
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
|
||||
let mut big_file = hidden_fs
|
||||
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Create big file");
|
||||
|
||||
let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes
|
||||
let mut payload = vec![0u8; payload_size];
|
||||
OsRng.fill_bytes(&mut payload);
|
||||
|
||||
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
|
||||
big_file.flush().await.expect("Flush big file");
|
||||
drop(big_file);
|
||||
|
||||
// Datei zurücklesen und Bit-für-Bit verifizieren
|
||||
let mut read_big = hidden_fs
|
||||
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open big file");
|
||||
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
|
||||
assert_eq!(read_big_bytes.len(), payload_size);
|
||||
assert_eq!(&read_big_bytes[..], &payload[..]);
|
||||
drop(read_big);
|
||||
|
||||
// Datei umbenennen
|
||||
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
|
||||
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
|
||||
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
|
||||
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
|
||||
|
||||
// Datei löschen (Blöcke werden geshreddert und freigegeben)
|
||||
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
|
||||
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
|
||||
|
||||
// Checkpoint SQLite
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG
|
||||
// Ein Angreifer besitzt nur das Decoy-Passwort (dek_0).
|
||||
// Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch.
|
||||
// ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln!
|
||||
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
|
||||
assert!(
|
||||
report.is_healthy(),
|
||||
"Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}",
|
||||
report.errors
|
||||
);
|
||||
assert_eq!(
|
||||
report.corrupted_chunks, 0,
|
||||
"Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!"
|
||||
);
|
||||
assert_eq!(
|
||||
report.orphan_nodes, 0,
|
||||
"Es darf keine verwaisten Knoten geben!"
|
||||
);
|
||||
|
||||
// Aufräumen
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_a_container_file_size_invariance() {
|
||||
let path = temp_db_path("carrier_size_invariance");
|
||||
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
let pass_decoy = "DecoyPass2026!";
|
||||
let pass_hidden = "HiddenPass2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
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((
|
||||
"virtual_disk.vhdx",
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.expect("Init carrier schema");
|
||||
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Initiale Dateigröße messen
|
||||
let initial_file_size = std::fs::metadata(&path).unwrap().len();
|
||||
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
|
||||
|
||||
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
|
||||
let hidden_fs = SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
auth_hidden.dek().clone(),
|
||||
auth_hidden.carrier_dek(),
|
||||
auth_hidden.carrier_node_id(),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
1,
|
||||
);
|
||||
|
||||
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
|
||||
let mut handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open file");
|
||||
|
||||
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
|
||||
OsRng.fill_bytes(&mut random_data);
|
||||
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
|
||||
handle.flush().await.expect("Flush 4MB");
|
||||
drop(handle);
|
||||
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen
|
||||
let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len();
|
||||
|
||||
// Die Dateigröße auf der Festplatte DARF NICHT WACHSEN!
|
||||
// Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben.
|
||||
assert_eq!(
|
||||
initial_file_size, size_after_hidden_writes,
|
||||
"Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}",
|
||||
initial_file_size, size_after_hidden_writes
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_carrier_file_drop_and_append_mode() {
|
||||
let path = temp_db_path("carrier_drop_append");
|
||||
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||
let carrier_name = "test_carrier.iso";
|
||||
|
||||
let pass_decoy = "DecoyPassword2026!";
|
||||
let pass_hidden = "HiddenSecretPassword2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_0, nonce_0, tag_0) =
|
||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
|
||||
let db = Database::open(&path).expect("Open database");
|
||||
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.expect("Init carrier schema");
|
||||
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
|
||||
let hidden_fs = SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
auth_hidden.dek().clone(),
|
||||
auth_hidden.carrier_dek(),
|
||||
auth_hidden.carrier_node_id(),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
1,
|
||||
);
|
||||
|
||||
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
|
||||
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
||||
let mut write_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open file for write");
|
||||
|
||||
let initial_data = b"Hello from unflushed write!";
|
||||
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
|
||||
// WICHTIG: KEIN write_handle.flush()! Nur drop:
|
||||
drop(write_handle);
|
||||
|
||||
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
||||
let mut read_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open file for read");
|
||||
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
|
||||
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
|
||||
drop(read_handle);
|
||||
|
||||
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
||||
let append_data = b" - Appended data at EOF!";
|
||||
let mut append_handle = hidden_fs
|
||||
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open file for append");
|
||||
|
||||
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
|
||||
drop(append_handle); // Drop sichert auch hier
|
||||
|
||||
// Prüfe den vollständigen Dateiinhalt nach Append
|
||||
let mut read_handle_2 = hidden_fs
|
||||
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||
.await
|
||||
.expect("Open file for read after append");
|
||||
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
|
||||
let mut expected = Vec::new();
|
||||
expected.extend_from_slice(initial_data);
|
||||
expected.extend_from_slice(append_data);
|
||||
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
|
||||
drop(read_handle_2);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
+1011
-3
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user