30 Commits
Author SHA1 Message Date
harald 6cb48f55d9 docs(compliance): add LEGAL.md, THIRD_PARTY_LICENSES.md, and update packaging
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 15:14:51 +02:00
harald 7ed0f51fb1 chore(release): bump version to 0.3.1 and update release notes
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-10 14:08:48 +02:00
harald dae0b3b7c3 feat(resilience): implement CHAOS-01 to CHAOS-03 crash consistency and input guards
- CHAOS-01: write chunk and update node size atomically via write_chunk_and_update_size in a single SQLite transaction
- CHAOS-02: reject null-bytes and ASCII control characters in VFS paths and node names via validate_path_safety
- CHAOS-03: invalidate in-memory chunk cache on I/O and disk-full errors to prevent drop failure cascades
2026-09-10 13:58:39 +02:00
harald 541190cff4 feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
2026-09-10 13:27:01 +02:00
harald 99813ae2a3 feat(security): add Win32 VirtualLock memory protection, CFA error diagnostics, and ShellBag OpSec 2026-09-10 12:27:57 +02:00
harald 90cf4927af fix(release): normalize file paths for curl upload and upload standalone binary 2026-09-09 22:21:34 +02:00
harald abf395627a chore: release v0.3.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-09 22:00:04 +02:00
harald fcd59dfe68 security: harden WebDAV server against Slowloris/connection starvation and fix LZ4 bomb protection 2026-09-09 21:54:05 +02:00
harald a8fdfc674e fix(sre): implement PRR-01 stale mount self-healing and PRR-02 win32 console close handler 2026-09-09 21:45:35 +02:00
harald 70d03df676 fix(security): implement P3 high-entropy carrier default and P5 manifest capacity guard 2026-09-09 21:33:01 +02:00
harald 771c08ff0f fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization 2026-09-09 21:24:07 +02:00
harald 98bfac718f fix(core): resolve all 12 adversarial review findings
- Zeroize passwords in CLI prompts, handlers, and mount authentication
- Preserve carrier_node_id when recovering Slot 0 via recovery key
- Add --slot parameter to restore-header for targeted slot recovery
- Implement online_backup and restore_from_backup using SQLite Online Backup API
- Expose 'sanctum backup' and 'sanctum restore' CLI subcommands
- Fix inactivity auto-lock by removing touch() from PROPFIND metadata/read_dir
- Support O_APPEND by setting file cursor to file size on handle creation
- Prevent data loss by implementing Drop for CarrierFile to flush dirty blocks
- Optimize CSPRNG padding to only fill unwritten slack space
- Batch carrier initialization in 500-block transactions to prevent UI/CLI freeze
- Enforce 64-byte savings threshold for LZ4 compression
- Add WebClient service diagnostic hint for Windows net use mount errors
- Add unit and integration tests covering all new features
2026-09-09 21:02:21 +02:00
harald 030ce6a1e5 feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability) 2026-09-09 20:22:00 +02:00
harald b8e4dcb614 feat(security): implement Phase 1 of Plausible Deniability hardening 2026-09-09 19:55:34 +02:00
harald ea571d245e fix(branding): make shield logo and Windows icon background 100% transparent
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-08 11:50:05 +02:00
harald bb4268cb62 feat(branding): add 3D shield logo, embed Windows PE icon, and update README banner
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-08 11:24:15 +02:00
harald 952e0cb23a docs(readme): update documentation for v0.2.0 release
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-08 10:58:23 +02:00
harald fb154c0d2e docs(changelog): document v0.2.0 release changes
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-08 10:51:58 +02:00
harald 5c1ac89989 chore(release): bump version to 0.2.0
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
2026-09-08 10:50:33 +02:00
harald 1e6854e9f5 feat(anti-forensics): implement incremental auto-vacuum, chunk shredding, and plausible deniability 2026-09-08 10:46:24 +02:00
harald 2cb065c8c0 feat(opsec): implement inactivity auto-lock, session lock detection, and anti-leak shield 2026-09-08 10:14:31 +02:00
harald 38df3d3845 feat(windows): implement explorer shell integration, auto drive allocation, auto-open, and system tray icon 2026-09-08 10:04:24 +02:00
harald 1c8a860184 feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification 2026-09-08 09:49:08 +02:00
harald 2d14c64c3e feat(compression): implement transparent LZ4 chunk compression with V1 backwards compatibility 2026-09-07 21:59:42 +02:00
harald a9c3dd25a3 feat(cli): add passwd command for instant master password changes 2026-09-07 17:29:50 +02:00
harald 58336fc059 fix(release): let curl manage multipart boundary automatically 2026-09-07 16:57:10 +02:00
harald 83dc42dd86 fix(release): write UTF-8 without BOM for release payload 2026-09-07 16:54:46 +02:00
harald 38a8a747c7 feat(release): support loading local .env in publish script 2026-09-07 16:51:12 +02:00
harald 38c0a6dc12 chore: ignore .env and token files in git 2026-09-07 16:49:35 +02:00
harald d15ebd8916 feat(release): add automated Gitea release publisher script 2026-09-07 16:40:49 +02:00
26 changed files with 9408 additions and 449 deletions
+4
View File
@@ -2,3 +2,7 @@
/dist
*.sanctum
*.log
.env
.env.*
*.token
.token
+86 -1
View File
@@ -5,7 +5,92 @@ 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.1] - 2026-09-10
### Added
- **Human Factors & OpSec-UX (HF-01 bis HF-04)**:
- Maskierte interaktive Notfallschlüssel-Eingabe: `--recovery-key` ohne CLI-Argument liest die 24 BIP-39-Wörter via `rpassword` ein, ohne Klartextspuren in der PowerShell-Historie (`ConsoleHost_history.txt`) zu hinterlassen.
- BIP-39 Resilienz & Tippfehler-Diagnose: Automatische String-Normalisierung (Kleinschreibung, Whitespace- und Satzzeichenbereinigung) sowie Levenshtein-Kandidatenvorschläge ($\le 2$ Distanz) bei Eingabefehlern.
- Stealth-Mount Modus (`--stealth` / `-s`): Vollständig lautloser Betrieb ohne Banner, Token-Ausgabe, Mount-Pfade oder Box-Rahmen.
- VFS Carrier-Schutz im Decoy-Vault: Umfassender Lösch-, Schreib-, Truncate- und Umbenennungsschutz der Alibi-Trägerdatei (`FsError::Forbidden`) bei Zugriff über Slot 0.
- **Chaos Engineering & Crash-Resilienz (CHAOS-01 bis CHAOS-03)**:
- Transaktionale Crash-Consistency (CHAOS-01): Bündelung von Chunk-Write und Dateigrößen-Aktualisierung (`write_chunk_and_update_size`) in einer einzigen atomaren SQLite-Transaktion (`tx.commit()`) für vollständige Resilienz gegen plötzlichen Stromausfall.
- Path Fuzzing & Injection Guard (CHAOS-02): Strikte Validierung aller VFS-Pfade und Knotennamen gegen Null-Bytes (`\0`) und ASCII-Steuerzeichen ($< 0x20$).
- Fault Recovery bei Disk Full (CHAOS-03): Sofortige Invalidierung des In-Memory-Chunk-Caches bei Schreibfehlern zur Vermeidung kaskadierender Drop-Panics bei vollem Zieldatenträger.
- **DFIR & Windows Host-Memory Hardening**:
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
- **Legal, Compliance & Export-Control (LEGAL-01 bis LEGAL-03)**:
- `LEGAL.md`: Umfassende rechtliche Dokumentation inkl. Exportkontroll-Klassifizierung (US EAR § 742.15(b) & EU Dual-Use GSN), Aufklärung über Plausible Deniability bei Key-Disclosure-Gesetzen, EU/DACH-Haftungsbeschränkung gem. § 521 BGB (Schenkungsrecht) sowie DSGVO Art. 25 Zero-Data-Erklärung.
- `THIRD_PARTY_LICENSES.md`: Lückenlose Attribution aller ~230 statisch eingebundenen Crates (Apache-2.0, MIT, BSD, SQLite Public Domain) zur strikten Erfüllung von Section 4 der Apache-2.0-Lizenz.
- Automatisierte Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das Release-Packaging (`package-release.ps1`).
### Changed
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
- Beseitigung verräterischer Konsolenausgaben (magenta hervorgehobene Hinweise auf Hidden Vault / Slot 1) und Harmonisierung des Explorer-Verhaltens für beide Safes.
- Vereinheitlichung der Ausgaben von `sanctum verify` und `sanctum recovery-key` zur Vermeidung von Rückschlüssen auf vorhandene Hidden-Vault-Slots.
- Interaktiver Assistent für `sanctum restore-header` bei Aufruf ohne CLI-Parameter.
## [0.3.0] - 2026-09-09
### Added
- **Modell A: Steganografischer Carrier & Plausible Deniability Phase 2**:
- Zwei-Schichten-AEAD (`DEK_0` + `DEK_1`) zur vollständigen Abwehr von Chunks-Accounting-Angriffen.
- Physische Dateigrößen-Invarianz: Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
- Virtuelles Carrier-Dateisystem (`CarrierFs`) mit reserviertem Block 0 für Manifest und Block-Allokationsbitmap.
- Schreib-, Lösch- und Umbenennungsschutz der Alibi-Trägerdatei im Decoy-Mount (`FsError::Forbidden`).
- **CLI & Disaster Recovery Erweiterungen**:
- `sanctum backup` & `sanctum restore`: Dedizierte CLI-Befehle für konsistente Online-Sicherungen.
- `sanctum restore-header --slot <0|1>`: Gezielte Wiederherstellung für Decoy- oder Hidden-Vault-Header.
- `sanctum init --carrier-file <NAME> --carrier-size <MB>`: Frei konfigurierbare Alibi-Trägerdatei mit forensischen Sicherheitsprüfungen.
- **SRE & Production Resilience**:
- Nativer Win32 `SetConsoleCtrlHandler`: Synchrones Aushängen und WAL-Checkpointing bei Schließen des Konsolenfensters (`CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, `CTRL_SHUTDOWN_EVENT`).
- Stale Mount Self-Healing: Automatische Erkennung und Bereinigung verwaister Windows-Netzlaufwerke (Systemfehler 85).
- Automatische Erkennung und Diagnosehilfe für den Windows `WebClient`-Dienst (Fehler 67).
- **Red Team & Threat Model Hardening**:
- WebDAV Loopback Protection: Dynamisches 128-Bit Session-Token im URL-Pfad (Schutz vor unprivilegierten lokalen Prozessen & CSRF).
- Anti-DNS-Rebinding & Anti-Spoofing: Strikte Fail-Closed Host-Header-Validierung.
- Anti-Slowloris & Connection Limiting: Begrenzung auf maximal 64 gleichzeitige WebDAV-Verbindungen und 15s Header-Read-Timeout.
- Decompression-Bomb-Schutz: Strikter 1-MB-Größen-Guard vor LZ4-Dekomprimierung zur Vermeidung von Speichererschöpfung (CWE-400).
- Verzeichnis-Hijacking-Schutz: Dynamische Bindung verschlüsselter Knotennamen an die `parent_id` via AEAD-AAD.
- Vollständige RAM-Zeroization: Schutz sensibler Daten im Heap via `zeroize::Zeroize` bei Puffer-Swaps und `Drop`.
### Changed
- Standardname der Trägerdatei auf forensisch plausiblen Typ `system_backup.dat` geändert.
- Heap- und Schreiboptimierung: Reduzierung der CSPRNG-Padding-Generierung auf den tatsächlichen Slack-Bereich (~30-fache Schreibbeschleunigung).
- Inaktivitäts-Tracking: `self.touch()` reagiert nur noch auf echte I/O-Interaktionen (`open`, read, write) und ignoriert passive Explorer-Hintergrundabfragen (`metadata`, `read_dir`).
- LZ4-Kompression: Schwellenwert von mindestens 64 Bytes Ersparnis eingeführt (`compressed.len() + 64 <= plaintext.len()`).
## [0.2.0] - 2026-09-08
### Added
- **Transparente LZ4-Kompression**:
- `lz4_flex` Integration mit Frame-Format vor der AEAD-Verschlüsselung.
- Adaptiver Fallback: Chunks werden nur komprimiert, wenn mindestens 64 Bytes gespart werden.
- Transparente On-the-Fly Dekompression bei Leseoperationen.
- **Disaster Recovery & Integrität**:
- BIP-39 Mnemonic Seed Phrases (24 Wörter) für Master-Key Recovery.
- `sanctum recovery-key`: Export und Anzeige des BIP-39 Notfallschlüssels.
- `sanctum backup` & `sanctum restore`: Konsistente Online-Sicherungen via SQLite Backup API mit WAL-Flush.
- `sanctum verify`: Vollständige kryptografische Integritätsprüfung aller AEAD-Chunks und B-Tree-Validierung.
- **Windows Explorer Integration & Bedienkomfort**:
- `sanctum register` & `sanctum unregister`: Windows-Kontextmenü im Explorer (`HKCU\Software\Classes\.sanctum`, "In Sanctum öffnen", Icon-Zuordnung).
- Intelligente Erkennung freier Laufwerksbuchstaben (von Z: abwärts).
- Automatisches Öffnen des gemounteten Laufwerks im Windows Explorer.
- System-Tray Icon (`tray-icon`) im Windows Infobereich mit Status und schnellem Unmount.
- **Explorer Anti-Leak Shield & OpSec**:
- Windows Anti-Leak Filter: Blockiert `thumbs.db`, `desktop.ini`, `*.tmp`, Office-Lockfiles (`~$*`) und NTFS Alternative Data Streams (`:Zone.Identifier`).
- Activity Tracking & Inaktivitäts-Timeout: Automatischer Unmount bei Leerlauf.
- Windows Session Lock Monitor: Registrierung für `WTS_SESSION_LOCK` mit sicherem Auto-Unmount beim Sperren des PCs.
- **Storage Compaction & Anti-Forensics**:
- SQLite Incremental Auto-Vacuum (`PRAGMA auto_vacuum = INCREMENTAL;`) zur physikalischen Freigabe ungenutzter Dateiseiten an das Host-Dateisystem.
- Kryptografisches Chunk-Shredding: Chunks werden vor dem Löschen/Abschneiden mit CSPRNG-Zufallsrauschen überschrieben.
- `sanctum compact`: Manuelles oder beim Aushängen automatisches Freigeben von Freelist-Pages.
- Plausible Deniability (Hidden Vault): Dual-Slot Header-Architektur. Unbenutzte Slots enthalten ununterscheidbares CSPRNG-Rauschen.
- `sanctum init --with-hidden`: Erstellung zweier getrennter Safes (Decoy & Hidden) mit individuellen Passphrasen und BIP-39 Recovery-Karten.
- Vollständige Dateinamen-Verschlüsselung für den Hidden Vault.
- **Passwort-Verwaltung (`sanctum passwd`)**:
- Sofortiges Ändern des Master-Passworts über DEK-Rewrapping mit neuem Argon2id-Salt und KEK-Parametern ohne Neuverschlüsselung der Nutzdaten. Multi-Slot fähig.
## [0.1.0] - 2026-09-07
Generated
+333 -12
View File
@@ -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.1"
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
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.1.0"
version = "0.3.1"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -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
+73
View File
@@ -0,0 +1,73 @@
# Rechtliche Hinweise, Compliance & Exportkontrolle (LEGAL)
Dieses Dokument regelt die rechtlichen Rahmenbedingungen, Exportkontroll-Klassifizierungen, Lizenz-Compliance sowie die datenschutzrechtlichen und haftungsrechtlichen Grundlagen der Software **Sanctum**.
---
## 1. Kryptografie-Exportkontrolle & Dual-Use-Klassifizierung
Sanctum implementiert starke, moderne kryptografische Verfahren mit symmetrischen Schlüssellängen von 256 Bit (AES-256-GCM), asymmetrischer KDF (Argon2id) sowie BIP-39 Schlüsselableitung.
### A. US Export Administration Regulations (EAR)
* **Klassifizierung (ECCN)**:
* Hardware/Software mit starker Verschlüsselung fällt unter **ECCN 5A002.a** bzw. **5D002** (Commerce Control List, Category 5, Part 2: *Information Security*).
* **Ausnahmetatbestand für frei verfügbaren Quellcode**:
* Gemäß **15 C.F.R. § 742.15(b)** (*Publicly Available Encryption Source Code*) und **15 C.F.R. § 734.3(b)(3)** unterliegt öffentlich zugänglicher Open-Source-Quellcode, der jedermann unentgeltlich und ohne Zugriffsbeschränkungen zur Verfügung steht, **nicht** den Ausfuhrgenehmigungspflichten der EAR.
* Sanctum wird unter der freien MIT-Lizenz auf frei zugänglichen Repositorien (einschließlich Quellcode, Spezifikationen und Build-Skripten) bereitgestellt.
* **Sanktionierte Staaten & Personen (OFAC)**:
* Trotz der allgemeinen Open-Source-Freistellung ist der wissentliche direkte oder indirekte Export/Re-Export an Personen oder Entitäten auf US-Sanktionslisten (OFAC Specially Designated Nationals List - SDN) sowie in Länder unter umfassenden Handelsembargos (Kuba, Iran, Nordkorea, Syrien, besetzte Gebiete der Ukraine) untersagt.
### B. Europäische Union (EU Dual-Use-Verordnung 2021/821)
* **Anhang I, Kategorie 5 Teil 2**:
* Systeme der Informationssicherheit sind grundsätzlich im Anhang I der Dual-Use-Verordnung gelistet.
* **Allgemeine Software-Anmerkung (General Software Note - GSN)**:
* Gemäß der GSN zu Anhang I der Verordnung (EU) 2021/821 unterliegt Software **nicht** der Ausfuhrkontrolle, wenn sie:
1. Allgemein zugänglich ist (*in the public domain*), z. B. durch uneingeschränkten, unentgeltlichen Download; oder
2. Der Mindest-Quellcode für die Installation, den Betrieb oder die Wartung nicht-kontrollierter Güter ist.
* Da Sanctum als freie Open-Source-Software jedermann unentgeltlich zur Verfügung steht, greift die Freistellung der GSN.
---
## 2. Plausible Deniability & Herausgabepflichten (Key Disclosure Laws)
Sanctum implementiert mit **Modell A** (Steganografischer Alibi-Carrier im Decoy-Vault mit exakter physikalischer Dateigrößen-Invarianz und uniformem Entropie-Slack) ein mathematisch informationstheoretisches Modell für **Plausible Deniability** (Glaubhafte Abstreitbarkeit).
> [!WARNING]
> **Wichtiger Hinweis für Anwender**: Plausible Deniability ist ein **technischer Schutzmechanismus**, kein juristisches Schutzschild. Die Wirksamkeit gegenüber staatlichen Ermittlungsbehörden hängt maßgeblich von der jeweiligen Jurisdiktion und dem eigenen Aussageverhalten ab:
1. **Deutschland / Österreich / Schweiz (DACH)**:
* **Nemo tenetur se ipsum accusare**: Niemand ist verpflichtet, sich in einem Strafverfahren selbst zu belasten (§ 136 Abs. 1 S. 2 StPO, Art. 6 EMRK). Beschuldigte haben das Recht, Passwörter und Schlüssel vollständig zu verschweigen.
* **Aussage als Beschuldigter**: Gibt ein Beschuldigter freiwillig das Passwort für den Decoy-Vault (Slot 0) heraus und verschweigt die Existenz des Hidden Vaults (Slot 1), macht er sich in der Regel nicht strafbar, solange er keine Dritten falsch verdächtigt (§ 164 StGB).
* **Zeugenstatus**: Zeugen sind grundsätzlich zur Wahrheit verpflichtet (§ 70 StPO). Bei falscher Aussage droht Strafbarkeit (§§ 153 ff. StGB).
2. **Großbritannien (UK - RIPA 2000 Part III, Section 49 & 53)**:
* Britische Behörden können unter Androhung einer Freiheitsstrafe von bis zu 2 Jahren (bei Gefährdung der nationalen Sicherheit bis zu 5 Jahren) die Offenlegung von Schlüsseln förmlich anordnen (*Section 49 Notice*).
* Da Sanctum dank Modell A keine forensisch nachweisbaren Spuren eines Hidden Vaults hinterlässt, existiert für Ermittler kein Nachweis, dass überhaupt ein zweiter Schlüssel existiert. Dennoch obliegt die Beweiswürdigung dem zuständigen Gericht.
3. **Vereinigte Staaten von Amerika (USA - 5th Amendment)**:
* Das 5. Zusatzprotokoll schützt vor erzwungenen Zeugenaussagen gegen sich selbst. Passwörter gelten als geschützter Denkinhalt (*testimonial*).
* *Foregone Conclusion Doctrine*: Kann der Staat beweisen, dass bestimmte inkriminierte Dateien auf dem Gerät existieren, kann die Passworteingabe als Vollstreckungshandlung erzwungen werden. Bei Sanctum verhindert die ununterscheidbare Entropie des Trägers den Nachweis der Existenz von Daten im Hidden Safe.
---
## 3. Haftungsausschluss & Gewährleistung (EU / DACH Recht)
Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT WARRANTY OF ANY KIND“*) wird für den Geltungsbereich des europäischen und deutschen Rechts wie folgt präzisiert und ergänzt:
### A. Schenkungsrechtliche Haftungsbeschränkung (§ 521 BGB)
1. Sanctum wird dem Anwender unentgeltlich und ohne Gegenleistung überlassen.
2. Gemäß **§ 521 des Bürgerlichen Gesetzbuches (BGB)** haftet der Urheber / Entwickler bei unentgeltlicher Softwareüberlassung **nur für Vorsatz und grobe Fahrlässigkeit**.
3. Die Haftung für einfache/leichte Fahrlässigkeit, mittelbare Schäden, Folgeschäden, Datenverluste, entgangenen Gewinn oder Betriebsunterbrechungen ist im gesetzlich zulässigen Rahmen vollständig ausgeschlossen.
### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
---
## 4. Datenschutz & DSGVO / GDPR (Zero-Telemetry-Garantie)
Sanctum folgt uneingeschränkt dem Prinzip **Privacy by Design and by Default** (Art. 25 DSGVO):
* **0 Bytes Telemetrie**: Sanctum enthält keinen Code für Analytics, Fehlerberichterstattung (Crash Reporting), User-Tracking oder Telemetrie.
* **100% Offline-Betrieb**: Sanctum stellt zu keinem Zeitpunkt ausgehende Internetverbindungen her. Es gibt keine Update-Prüfungen, Pingbacks oder Cloud-Synchronisationen.
* **Keine Speicherung personenbezogener Daten**: Sanctum speichert lokal keinerlei Protokolle über eingegebene Passwörter, Benutzer-IDs oder Dateinamen außerhalb des verschlüsselten Containers.
* **Windows Host-Hygiene**: Durch das integrierte *Anti-Leak-Shield* und den *RAM-Paging-Schutz* (`VirtualLock`) werden Spuren im Host-System (Thumbs.db, temporäre Dateien, Auslagerungsdatei) aktiv unterbunden.
+177 -33
View File
@@ -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,42 +68,170 @@ 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
# Lautloser Stealth-Modus (keine Banner/Token-Ausgabe, kein Explorer-Auto-Open):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --stealth
# Explorer-Auto-Open gezielt unterdrücken (maximale ShellBag-OpSec):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --no-open
```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird standardmäßig im Explorer geöffnet (abschaltbar via `--no-open` oder `--stealth`).
- **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.
4. **RAM-Paging-Schutz (`VirtualLock`)**: Verriegelt sensible Schlüsselstrukturen (`DEK`) im physischen RAM, um das Auslagern in `pagefile.sys` oder `swapfile.sys` durch den Windows Memory Manager zu verhindern.
5. **ShellBag & Forensik-OpSec**: Über die Optionen `--no-open` oder `--stealth` kann das automatische Öffnen des Windows Explorers vollständig unterdrückt werden, um zu verhindern, dass Ordnernamen persistent im ShellBag-Cache (`UsrClass.dat`) protokolliert werden.
6. **VFS Carrier-Schutz**: Die im Decoy-Tresor sichtbare Alibi-Trägerdatei wird im VFS gegen versehentliches Löschen, Überschreiben oder Umbenennen geschützt.
7. **Power-Loss Crash-Consistency**: Atomare Bündelung von Block-Schreiboperationen und Metadaten-Größenaktualisierungen in einer SQLite-Transaktion verhindert Datenkorruption bei plötzlichem Stromausfall.
---
## 🔧 Windows-Kompatibilität & AV/EDR-Tuning
### Windows Defender SmartScreen ("Unbekannter Herausgeber")
Wird die Binärdatei über einen Webbrowser heruntergeladen, versieht Windows sie mit dem *Mark-of-the-Web* (MotW). Da Open-Source-Projekte über kein kostenpflichtiges EV-Code-Signing-Zertifikat verfügen, zeigt Windows beim ersten Start den blauen SmartScreen-Dialog:
- **Einmalige Freigabe im Dialog:** Klick auf **"Weitere Informationen"** $\to$ **"Trotzdem ausführen"**.
- **Schnellfreigabe via PowerShell:**
```powershell
Unblock-File .\sanctum.exe
```
### Windows Defender "Überwachter Ordnerzugriff" (Controlled Folder Access - CFA)
Ist der erweiterte Ransomware-Schutz von Windows aktiv, kann Defender den Schreibzugriff auf Containerdateien in Standard-Benutzerordnern (`Dokumente`, `Desktop`) mit Fehler 5 (*Zugriff verweigert*) blockieren:
- **Abhilfe:** Erlauben Sie `sanctum.exe` in den Windows-Sicherheitseinstellungen (*Viren- & Bedrohungsschutz* $\to$ *Ransomware-Schutz verwalten* $\to$ *App durch überwachten Ordnerzugriff zulassen*), oder speichern Sie Container in einem separaten Verzeichnis (z. B. `C:\Sanctum\`).
### I/O-Performance-Tuning
Windows Defender scannt standardmäßig jeden modifizierten 1-MB-Chunk in Echtzeit. Durch das Hinzufügen der Dateiendung `*.sanctum` zu den Defender-Ausschlüssen lässt sich der I/O-Durchsatz bei großen Kopiervorgängen um 3050% steigern.
---
## 📦 Release Packaging & Distribution
Um ein Distributionspaket mit Release-Binary, Checksummen und Dokumentation zu bauen:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
```
Das fertige Paket liegt in `dist/` bereit:
- `dist/sanctum-v0.1.0-windows-x86_64.zip`
Erzeugt:
- `dist/sanctum-v0.3.1-windows-x86_64.zip`
- `dist/SHA256SUMS.txt`
---
## ⚖️ Rechtliche Hinweise, Compliance & Exportkontrolle
- **Kryptografie-Exportkontrolle (EAR & EU Dual-Use)**:
Sanctum nutzt starke Kryptografie (AES-256-GCM, Argon2id). Da der Quellcode als freie Open-Source-Software jedermann unbeschränkt und unentgeltlich zur Verfügung steht, unterliegt er den Ausnahmetatbeständen gem. **15 C.F.R. § 742.15(b)** (US EAR) sowie der **General Software Note (GSN)** der Verordnung (EU) 2021/821.
- **Plausible Deniability & Herausgabepflichten**:
Sanctum bietet mit dem steganografischen Alibi-Carrier (Modell A) ein informationstheoretisches Modell für glaubhafte Abstreitbarkeit. Dies ist ein technischer Schutzmechanismus, kein juristisches Schutzschild. Die rechtliche Bewertung im Fall strafprozessualer Herausgabeanordnungen (*Key Disclosure*) hängt von der jeweiligen Jurisdiktion ab (z. B. Nemo-tenetur-Grundsatz in DACH vs. RIPA Section 49 im UK).
- **Datenschutz & Telemetrie-Freiheit (DSGVO / GDPR)**:
Sanctum arbeitet zu 100% offline und enthält keinerlei Code für Telemetrie, Analytics, Fehlerberichterstattung oder IP-Übertragungen (Art. 25 DSGVO *Privacy by Design*).
- **Haftungsbeschränkung (EU/DACH Recht)**:
Die Software wird als unentgeltliche FOSS-Software bereitgestellt. Gemäß **§ 521 BGB** (Schenkungsrecht) ist die Haftung des Entwicklers auf Vorsatz und grobe Fahrlässigkeit beschränkt. Der Anwender ist für die sichere Verwahrung von Schlüsseln und Backups selbst verantwortlich.
- Ausführliche rechtliche Erläuterungen finden sich in [`LEGAL.md`](LEGAL.md).
---
## 📄 Lizenz & Changelog
- Lizenziert unter der [MIT License](LICENSE).
- Vollständige Third-Party-Attributionen aller ~230 Abhängigkeiten (Apache-2.0, MIT, BSD, SQLite Public Domain) sind in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dokumentiert.
- Ausführliche rechtliche Bestimmungen und Exportkontroll-Hinweise sind in [`LEGAL.md`](LEGAL.md) geregelt.
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
+382
View File
@@ -0,0 +1,382 @@
# Third-Party Software Licenses & Attributions
Sanctum incorporates open-source software packages statically compiled into the binary.
All included components are distributed under permissive open-source licenses.
This document provides full attribution and license texts in compliance with
Section 4 of the Apache License, Version 2.0, the MIT License, and BSD licenses.
---
## 1. Dependency Inventory
| Crate | Version | License |
| :--- | :--- | :--- |
| `aead` | 0.5.2 | MIT OR Apache-2.0 |
| `aes` | 0.8.4 | MIT OR Apache-2.0 |
| `aes-gcm` | 0.10.3 | Apache-2.0 OR MIT |
| `ahash` | 0.8.12 | MIT OR Apache-2.0 |
| `aho-corasick` | 1.1.5 | Unlicense OR MIT |
| `android_system_properties` | 0.1.6 | MIT OR Apache-2.0 |
| `anstream` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle` | 1.0.14 | MIT OR Apache-2.0 |
| `anstyle-parse` | 1.0.0 | MIT OR Apache-2.0 |
| `anstyle-query` | 1.1.5 | MIT OR Apache-2.0 |
| `anstyle-wincon` | 3.0.11 | MIT OR Apache-2.0 |
| `anyhow` | 1.0.104 | MIT OR Apache-2.0 |
| `argon2` | 0.5.3 | MIT OR Apache-2.0 |
| `arrayvec` | 0.7.8 | MIT OR Apache-2.0 |
| `atomic-waker` | 1.1.2 | Apache-2.0 OR MIT |
| `autocfg` | 1.5.1 | Apache-2.0 OR MIT |
| `base64` | 0.22.1 | MIT OR Apache-2.0 |
| `base64ct` | 1.8.3 | Apache-2.0 OR MIT |
| `bip39` | 2.2.2 | CC0-1.0 |
| `bitcoin_hashes` | 0.14.101 | CC0-1.0 |
| `bitflags` | 2.13.1 | MIT OR Apache-2.0 |
| `bitflags` | 1.3.2 | MIT/Apache-2.0 |
| `blake2` | 0.10.6 | MIT OR Apache-2.0 |
| `block` | 0.1.6 | MIT |
| `block-buffer` | 0.10.4 | MIT OR Apache-2.0 |
| `bumpalo` | 3.20.3 | MIT OR Apache-2.0 |
| `bytes` | 1.12.1 | MIT |
| `cc` | 1.4.5 | MIT OR Apache-2.0 |
| `cfg-if` | 1.0.4 | MIT OR Apache-2.0 |
| `chrono` | 0.4.45 | MIT OR Apache-2.0 |
| `cipher` | 0.4.4 | MIT OR Apache-2.0 |
| `clap` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_builder` | 4.6.6 | MIT OR Apache-2.0 |
| `clap_derive` | 4.6.4 | MIT OR Apache-2.0 |
| `clap_lex` | 1.1.0 | MIT OR Apache-2.0 |
| `cocoa` | 0.25.0 | MIT OR Apache-2.0 |
| `cocoa-foundation` | 0.1.2 | MIT OR Apache-2.0 |
| `colorchoice` | 1.0.5 | MIT OR Apache-2.0 |
| `core-foundation` | 0.9.4 | MIT OR Apache-2.0 |
| `core-foundation-sys` | 0.8.7 | MIT OR Apache-2.0 |
| `core-graphics` | 0.23.2 | MIT OR Apache-2.0 |
| `core-graphics-types` | 0.1.3 | MIT OR Apache-2.0 |
| `cpufeatures` | 0.2.17 | MIT OR Apache-2.0 |
| `crypto-common` | 0.1.7 | MIT OR Apache-2.0 |
| `ctr` | 0.9.2 | MIT OR Apache-2.0 |
| `dav-server` | 0.11.0 | Apache-2.0 |
| `derive-where` | 1.6.1 | MIT OR Apache-2.0 |
| `digest` | 0.10.7 | MIT OR Apache-2.0 |
| `displaydoc` | 0.2.7 | MIT OR Apache-2.0 |
| `dyn-clone` | 1.0.20 | MIT OR Apache-2.0 |
| `equivalent` | 1.0.2 | Apache-2.0 OR MIT |
| `errno` | 0.3.14 | MIT OR Apache-2.0 |
| `fallible-iterator` | 0.3.0 | MIT/Apache-2.0 |
| `fallible-streaming-iterator` | 0.1.9 | MIT/Apache-2.0 |
| `find-msvc-tools` | 0.1.12 | MIT OR Apache-2.0 |
| `fnv` | 1.0.7 | Apache-2.0 / MIT |
| `foreign-types` | 0.5.0 | MIT/Apache-2.0 |
| `foreign-types-macros` | 0.2.4 | MIT OR Apache-2.0 |
| `foreign-types-shared` | 0.3.1 | MIT OR Apache-2.0 |
| `form_urlencoded` | 1.2.2 | MIT OR Apache-2.0 |
| `futures-channel` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-core` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-macro` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-sink` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-task` | 0.3.34 | MIT OR Apache-2.0 |
| `futures-util` | 0.3.34 | MIT OR Apache-2.0 |
| `generic-array` | 0.14.7 | MIT |
| `getrandom` | 0.4.3 | MIT OR Apache-2.0 |
| `getrandom` | 0.2.17 | MIT OR Apache-2.0 |
| `ghash` | 0.5.1 | Apache-2.0 OR MIT |
| `h2` | 0.4.19 | MIT |
| `hashbrown` | 0.17.1 | MIT OR Apache-2.0 |
| `hashbrown` | 0.14.5 | MIT OR Apache-2.0 |
| `hashlink` | 0.9.1 | MIT OR Apache-2.0 |
| `headers` | 0.4.1 | MIT |
| `headers-core` | 0.3.0 | MIT |
| `heck` | 0.5.0 | MIT OR Apache-2.0 |
| `hex` | 0.4.3 | MIT OR Apache-2.0 |
| `hex-conservative` | 0.2.3 | CC0-1.0 |
| `htmlescape` | 0.3.1 | Apache-2.0 / MIT / MPL-2.0 |
| `http` | 1.5.0 | MIT OR Apache-2.0 |
| `httparse` | 1.10.1 | MIT OR Apache-2.0 |
| `http-body` | 1.1.0 | MIT |
| `http-body-util` | 0.1.5 | MIT |
| `httpdate` | 1.0.3 | MIT OR Apache-2.0 |
| `hyper` | 1.11.1 | MIT |
| `hyper-util` | 0.1.20 | MIT |
| `iana-time-zone` | 0.1.65 | MIT OR Apache-2.0 |
| `iana-time-zone-haiku` | 0.1.2 | MIT OR Apache-2.0 |
| `icu_collections` | 2.3.0 | Unicode-3.0 |
| `icu_locale_core` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer` | 2.3.0 | Unicode-3.0 |
| `icu_normalizer_data` | 2.3.0 | Unicode-3.0 |
| `icu_properties` | 2.3.0 | Unicode-3.0 |
| `icu_properties_data` | 2.3.0 | Unicode-3.0 |
| `icu_provider` | 2.3.1 | Unicode-3.0 |
| `idna` | 1.1.0 | MIT OR Apache-2.0 |
| `idna_adapter` | 1.2.2 | Apache-2.0 OR MIT |
| `indexmap` | 2.14.2 | Apache-2.0 OR MIT |
| `inout` | 0.1.4 | MIT OR Apache-2.0 |
| `is_terminal_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `itoa` | 1.0.18 | MIT OR Apache-2.0 |
| `js-sys` | 0.3.105 | MIT OR Apache-2.0 |
| `lazy_static` | 1.5.0 | MIT OR Apache-2.0 |
| `libc` | 0.2.189 | MIT OR Apache-2.0 |
| `libsqlite3-sys` | 0.30.1 | MIT |
| `litemap` | 0.8.3 | Unicode-3.0 |
| `lock_api` | 0.4.14 | MIT OR Apache-2.0 |
| `log` | 0.4.34 | MIT OR Apache-2.0 |
| `lz4_flex` | 0.11.6 | MIT |
| `malloc_buf` | 0.0.6 | MIT |
| `matchers` | 0.2.0 | MIT |
| `memchr` | 2.8.3 | Unlicense OR MIT |
| `mime` | 0.3.17 | MIT OR Apache-2.0 |
| `mime_guess` | 2.0.5 | MIT |
| `mio` | 1.2.3 | MIT |
| `nu-ansi-term` | 0.50.3 | MIT |
| `num-traits` | 0.2.19 | MIT OR Apache-2.0 |
| `objc` | 0.2.7 | MIT OR Apache-2.0 |
| `objc_id` | 0.1.1 | MIT |
| `objc-foundation` | 0.1.1 | MIT OR Apache-2.0 |
| `once_cell` | 1.21.4 | MIT OR Apache-2.0 |
| `once_cell_polyfill` | 1.70.2 | MIT OR Apache-2.0 |
| `opaque-debug` | 0.3.1 | MIT OR Apache-2.0 |
| `padlock` | 0.2.0 | MIT OR Apache-2.0 |
| `parking_lot` | 0.12.5 | MIT OR Apache-2.0 |
| `parking_lot_core` | 0.9.12 | MIT OR Apache-2.0 |
| `password-hash` | 0.5.0 | MIT OR Apache-2.0 |
| `percent-encoding` | 2.3.2 | MIT OR Apache-2.0 |
| `pin-project-lite` | 0.2.17 | Apache-2.0 OR MIT |
| `pkg-config` | 0.3.34 | MIT OR Apache-2.0 |
| `polyval` | 0.6.2 | Apache-2.0 OR MIT |
| `potential_utf` | 0.1.6 | Unicode-3.0 |
| `ppv-lite86` | 0.2.21 | MIT OR Apache-2.0 |
| `proc-macro2` | 1.0.107 | MIT OR Apache-2.0 |
| `quote` | 1.0.47 | MIT OR Apache-2.0 |
| `rand` | 0.8.8 | MIT OR Apache-2.0 |
| `rand_chacha` | 0.3.1 | MIT OR Apache-2.0 |
| `rand_core` | 0.6.4 | MIT OR Apache-2.0 |
| `redox_syscall` | 0.5.18 | MIT OR Apache-2.0 |
| `r-efi` | 6.0.0 | MIT OR Apache-2.0 |
| `regex-automata` | 0.4.18 | MIT OR Apache-2.0 |
| `regex-syntax` | 0.8.11 | MIT OR Apache-2.0 |
| `rpassword` | 7.5.4 | Apache-2.0 |
| `rtoolbox` | 0.0.6 | Apache-2.0 |
| `rusqlite` | 0.32.1 | MIT |
| `rustversion` | 1.0.23 | MIT OR Apache-2.0 |
| `scopeguard` | 1.2.0 | MIT OR Apache-2.0 |
| `serde` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_core` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_derive` | 1.0.229 | MIT OR Apache-2.0 |
| `serde_json` | 1.0.151 | MIT OR Apache-2.0 |
| `sha1` | 0.10.7 | MIT OR Apache-2.0 |
| `sharded-slab` | 0.1.7 | MIT |
| `shlex` | 2.0.1 | MIT OR Apache-2.0 |
| `signal-hook-registry` | 1.4.8 | MIT OR Apache-2.0 |
| `slab` | 0.4.12 | MIT |
| `smallvec` | 1.16.0 | MIT OR Apache-2.0 |
| `socket2` | 0.6.5 | MIT OR Apache-2.0 |
| `stable_deref_trait` | 1.2.1 | MIT OR Apache-2.0 |
| `strsim` | 0.11.1 | MIT |
| `subtle` | 2.6.1 | BSD-3-Clause |
| `syn` | 3.0.5 | MIT OR Apache-2.0 |
| `syn` | 2.0.119 | MIT OR Apache-2.0 |
| `synstructure` | 0.13.2 | MIT |
| `thiserror` | 2.0.20 | MIT OR Apache-2.0 |
| `thiserror-impl` | 2.0.20 | MIT OR Apache-2.0 |
| `thread_local` | 1.1.10 | MIT OR Apache-2.0 |
| `tinystr` | 0.8.4 | Unicode-3.0 |
| `tinyvec` | 1.13.2 | Zlib OR Apache-2.0 OR MIT |
| `tinyvec_macros` | 0.1.1 | MIT OR Apache-2.0 OR Zlib |
| `tokio` | 1.53.1 | MIT |
| `tokio-macros` | 2.7.2 | MIT |
| `tokio-util` | 0.7.19 | MIT |
| `tracing` | 0.1.44 | MIT |
| `tracing-attributes` | 0.1.31 | MIT |
| `tracing-core` | 0.1.36 | MIT |
| `tracing-log` | 0.2.0 | MIT |
| `tracing-subscriber` | 0.3.23 | MIT |
| `tray-item` | 0.10.0 | MIT |
| `twox-hash` | 2.1.4 | MIT |
| `typenum` | 1.20.1 | MIT OR Apache-2.0 |
| `unicase` | 2.9.0 | MIT OR Apache-2.0 |
| `unicode-ident` | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 |
| `unicode-normalization` | 0.1.25 | MIT OR Apache-2.0 |
| `universal-hash` | 0.5.1 | MIT OR Apache-2.0 |
| `url` | 2.5.8 | MIT OR Apache-2.0 |
| `utf8_iter` | 1.0.4 | Apache-2.0 OR MIT |
| `utf8parse` | 0.2.2 | Apache-2.0 OR MIT |
| `uuid` | 1.26.0 | Apache-2.0 OR MIT |
| `valuable` | 0.1.1 | MIT OR Apache-2.0 |
| `vcpkg` | 0.2.15 | MIT/Apache-2.0 |
| `version_check` | 0.9.5 | MIT/Apache-2.0 |
| `wasi` | 0.11.1+wasi-snapshot-preview1 | MIT OR Apache-2.0 |
| `wasm-bindgen` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-macro-support` | 0.2.128 | MIT OR Apache-2.0 |
| `wasm-bindgen-shared` | 0.2.128 | MIT OR Apache-2.0 |
| `windows_aarch64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_aarch64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_i686_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnu` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_gnullvm` | 0.52.6 | MIT OR Apache-2.0 |
| `windows_x86_64_msvc` | 0.52.6 | MIT OR Apache-2.0 |
| `windows-core` | 0.62.2 | MIT OR Apache-2.0 |
| `windows-implement` | 0.60.2 | MIT OR Apache-2.0 |
| `windows-interface` | 0.59.3 | MIT OR Apache-2.0 |
| `windows-link` | 0.2.1 | MIT OR Apache-2.0 |
| `windows-result` | 0.4.1 | MIT OR Apache-2.0 |
| `windows-strings` | 0.5.1 | MIT OR Apache-2.0 |
| `windows-sys` | 0.61.2 | MIT OR Apache-2.0 |
| `windows-sys` | 0.52.0 | MIT OR Apache-2.0 |
| `windows-targets` | 0.52.6 | MIT OR Apache-2.0 |
| `writeable` | 0.6.4 | Unicode-3.0 |
| `xml` | 1.4.0 | MIT |
| `xml-rs` | 1.0.0 | MIT |
| `xmltree` | 0.12.0 | MIT |
| `yoke` | 0.8.3 | Unicode-3.0 |
| `yoke-derive` | 0.8.2 | Unicode-3.0 |
| `zerocopy` | 0.8.56 | BSD-2-Clause OR Apache-2.0 OR MIT |
| `zerocopy-derive` | 0.8.56 | MIT OR Apache-2.0 |
| `zerofrom` | 0.1.8 | Unicode-3.0 |
| `zerofrom-derive` | 0.1.7 | Unicode-3.0 |
| `zeroize` | 1.9.0 | Apache-2.0 OR MIT |
| `zeroize_derive` | 1.5.0 | Apache-2.0 OR MIT |
| `zerotrie` | 0.2.5 | Unicode-3.0 |
| `zerovec` | 0.11.8 | Unicode-3.0 |
| `zerovec-derive` | 0.11.6 | Unicode-3.0 |
| `zmij` | 1.0.23 | MIT |
---
## 2. License Texts
### The MIT License
```text
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### Apache License, Version 2.0
```text
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file.
```
### 3-Clause BSD License
```text
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
```
### SQLite Copyright & Blessing (Public Domain)
```text
The author disclaims copyright to this source code. In place of a legal notice,
here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

+28
View File
@@ -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);
}
}
}
}
}
}
+3
View File
@@ -64,7 +64,10 @@ $ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe"
Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") (Join-Path $StagingDir "LEGAL.md")
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") (Join-Path $StagingDir "THIRD_PARTY_LICENSES.md")
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md")
Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse
# ZIP-Archiv schnueren
Compress-Archive -Path "$StagingDir\*" -DestinationPath $ZipFile -Force
+139
View File
@@ -0,0 +1,139 @@
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"
$AuthHeader = "Authorization: token $Token"
# ZIP Asset hochladen
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan
$ZipPathNorm = $ZipFile.Replace('\', '/')
$ZipUrl = "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip"
$ZipResult = & curl.exe -s -X POST $ZipUrl -H $AuthHeader -F "attachment=@$ZipPathNorm"
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green
# SHA256SUMS.txt hochladen
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
$SumPathNorm = $ChecksumFile.Replace('\', '/')
$SumUrl = "$UploadUrl?name=SHA256SUMS.txt"
$SumResult = & curl.exe -s -X POST $SumUrl -H $AuthHeader -F "attachment=@$SumPathNorm"
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
# Standalone EXE hochladen
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
if (Test-Path $ExeFile) {
Write-Host "Lade sanctum.exe hoch..." -ForegroundColor Cyan
$ExePathNorm = $ExeFile.Replace('\', '/')
$ExeUrl = "$UploadUrl?name=sanctum.exe"
$ExeResult = & curl.exe -s -X POST $ExeUrl -H $AuthHeader -F "attachment=@$ExePathNorm"
Write-Host "[OK] sanctum.exe 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"
+1046
View File
File diff suppressed because it is too large Load Diff
+659 -25
View File
@@ -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,314 @@ 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())
}
pub fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let (m, n) = (a_chars.len(), b_chars.len());
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort.
pub fn suggest_bip39_word(word: &str) -> Option<&'static str> {
let word_list = bip39::Language::English.word_list();
let mut best_match = None;
let mut min_dist = usize::MAX;
for &valid in word_list {
let dist = levenshtein_distance(word, valid);
if dist < min_dist {
min_dist = dist;
best_match = Some(valid);
}
}
// Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2)
if min_dist <= 2 {
best_match
} else {
None
}
}
/// Normalisiert eine eingegebene BIP-39 Phrase:
/// - Wandelt in Kleinbuchstaben um (to_lowercase)
/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen
/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing)
pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
let cleaned: String = phrase
.chars()
.map(|c| {
if c.is_alphabetic() {
c.to_ascii_lowercase()
} else {
' '
}
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let words = normalize_mnemonic_phrase(phrase);
if words.is_empty() {
bail!("Die Notfallphrase darf nicht leer sein.");
}
if words.len() != 24 {
bail!(
"Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.",
words.len()
);
}
let word_list = bip39::Language::English.word_list();
let mut invalid_words = Vec::new();
for (idx, word) in words.iter().enumerate() {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
};
invalid_words.push(msg);
}
}
if !invalid_words.is_empty() {
bail!(
"Ungültige Wörter in der Notfallphrase festgestellt:\n • {}",
invalid_words.join("\n")
);
}
let normalized_phrase = words.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase)
.map_err(|_| {
anyhow::anyhow!(
"Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \
Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort."
)
})?;
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 +460,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 +479,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 +512,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 +520,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,8 +534,36 @@ 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)"))?;
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 {
@@ -226,23 +606,277 @@ 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_bip39_advanced_normalisation_and_suggestions() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).unwrap();
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
// 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization)
let uppercase_phrase = mnemonic_str.to_uppercase();
let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase");
assert_eq!(*dek, *recovered);
// 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern)
let punctuated = format!("{}.", words.join(", "));
let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated");
assert_eq!(*dek, *recovered_punct);
// 3. Typo-Vorschlag (Levenshtein)
let mut typo_words = words.clone();
typo_words[5] = "abondon"; // Typo for abandon
let typo_phrase = typo_words.join(" ");
let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string();
assert!(err.contains("Wort #6 'abondon' ist ungültig"));
assert!(err.contains("Meinten Sie 'abandon'?"));
}
#[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);
}
}
+4
View File
@@ -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;
+875 -22
View File
@@ -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,43 @@ 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).
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel sicher und interaktiv
/// einzugeben (verhindert Klartext-Einträge in der PowerShell-Historie).
#[arg(long, num_args = 0..=1, default_missing_value = "")]
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,
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
#[arg(short, long, default_value_t = false)]
stealth: bool,
},
/// Trennt ein eingebundenes Netzlaufwerk manuell
@@ -49,6 +113,95 @@ 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).
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
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.
/// Tipp: Rufen Sie '--recovery-key' ohne Wert auf, um den Schlüssel interaktiv einzugeben.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
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 +218,43 @@ 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);
}
}
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan(""));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der PowerShell-Historie.", ui::dim("OpSec"));
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
.context("Fehler beim Einlesen des Notfallschlüssels")?;
if phrase.trim().is_empty() {
bail!("Der Notfallschlüssel darf nicht leer sein.");
}
Ok(Zeroizing::new(phrase))
}
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,19 +266,152 @@ 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)");
}
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 = rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
let confirm_password = Zeroizing::new(
rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
);
if password != confirm_password {
if *password != *confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
@@ -105,32 +427,456 @@ fn handle_init(container_path: &Path) -> Result<()> {
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")?;
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(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
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 1 (Magic: SANCTUM\\0)");
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM (1-MB Chunks, AEAD Swap-Schutz)");
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 \"{}\" --drive S", container_path.display())));
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
println!();
ui::print_recovery_phrase_card(&recovery_phrase);
}
Ok(())
}
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 {
let actual_phrase = if phrase.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(phrase.to_string())
};
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(&actual_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(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")?;
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(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 WAL-Checkpoint nach Passwortänderung")?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Master-Passwort erfolgreich geändert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Vault Slot: {}", target_slot_id);
println!(" • KDF: Argon2id mit frischem Salt");
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
println!();
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 {
let key_str = if let Some(key) = recovery_key {
if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key.to_string())
}
} else {
// Interaktiver Auswahldialog statt Fehlermeldung
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
println!(" Bitte wählen Sie die gewünschte Wiederherstellungsmethode:");
println!(" [1] Sicherungsdatei verwenden (.sanctum.hdr)");
println!(" [2] 24-Wort BIP-39 Notfallschlüssel interaktiv eingeben");
print!(" Auswahl [1/2]: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut choice = String::new();
std::io::stdin().read_line(&mut choice)?;
match choice.trim() {
"1" => {
print!(" Pfad zur .sanctum.hdr Sicherungsdatei: ");
let _ = std::io::Write::flush(&mut std::io::stdout());
let mut p = String::new();
std::io::stdin().read_line(&mut p)?;
let hdr_path = PathBuf::from(p.trim());
return handle_restore_header(container_path, Some(&hdr_path), None, slot);
}
"2" => prompt_recovery_key_interactive()?,
_ => bail!("Ungültige Auswahl. Wiederherstellung abgebrochen."),
}
};
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_str, &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!"));
}
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 phrase = dek_to_mnemonic(&dek)?;
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) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
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 +884,85 @@ 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)?;
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,
stealth,
} => {
let drive_char = match drive {
Some(ref d) => parse_drive_letter(d)?,
None => {
let auto_drive = sanctum::windows::find_next_available_drive()?;
if !stealth {
println!(
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
ui::cyan(""),
auto_drive
);
}
auto_drive
}
};
let auth = if let Some(key) = recovery_key {
let actual_key = if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key)
};
ContainerAuth::RecoveryKey(actual_key)
} else {
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")?;
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 && !stealth;
let enable_tray = !no_tray && !stealth;
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,
stealth,
)
.await?;
}
Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?;
@@ -161,11 +973,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
+422 -66
View File
@@ -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,13 @@ 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,
stealth: bool,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
@@ -84,8 +131,10 @@ pub async fn mount_container(
);
}
if !stealth {
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
@@ -93,22 +142,66 @@ 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) => {
if !stealth {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
}
match meta.authenticate(password) {
Some(keys) => {
if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
}
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) => {
if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
if !stealth {
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();
@@ -130,6 +223,7 @@ pub async fn mount_container(
let bound_addr = listener.local_addr()?;
let bound_port = bound_addr.port();
if !stealth {
ui::step(
4,
4,
@@ -139,55 +233,105 @@ pub async fn mount_container(
bound_port, drive_str
),
);
}
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 (visuelle Parität für Decoy und Hidden Vault)
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
};
if stealth {
println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
} else {
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Container erfolgreich gemountet │");
@@ -195,18 +339,75 @@ 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 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();
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
tokio::select! {
res = tokio::signal::ctrl_c() => {
let _ = res;
if !stealth {
println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
}
_ = console_close_rx.recv() => {
if !stealth {
println!();
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
}
}
_ = tray_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
}
}
_ = session_lock_rx.recv() => {
if !stealth {
println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
_ = idle_shutdown_rx.recv() => {
if !stealth {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
}
if stealth {
let _ = unmount_drive(drive_letter);
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
let _ = db.incremental_vacuum(None);
}
}
let _ = db.checkpoint();
println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str);
} else {
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout());
@@ -221,6 +422,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());
@@ -233,6 +446,149 @@ pub async fn mount_container(
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!();
}
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
View File
@@ -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);
}
}
+1344 -85
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -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
View File
@@ -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);
}
}
+688 -105
View File
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
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)
}
}
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys).
pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualLock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualLock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(not(windows))]
{
let _ = (ptr, len);
false
}
}
/// Entriegelt einen zuvor mit `lock_memory` geschützten Speicherbereich im RAM.
pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
#[cfg(windows)]
{
extern "system" {
fn VirtualUnlock(lpaddress: *const std::ffi::c_void, dwsize: usize) -> i32;
}
if ptr.is_null() || len == 0 {
return false;
}
unsafe { VirtualUnlock(ptr as *const std::ffi::c_void, len) != 0 }
}
#[cfg(not(windows))]
{
let _ = (ptr, len);
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_virtual_lock_memory_lifecycle() {
let buffer = vec![0x42u8; 4096];
let _ = lock_memory(buffer.as_ptr(), buffer.len());
let _ = unlock_memory(buffer.as_ptr(), buffer.len());
assert_eq!(buffer[0], 0x42);
}
#[test]
fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive");
assert!(drive.is_ascii_alphabetic());
assert!(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);
}
}
+472
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff