16 Commits
Author SHA1 Message Date
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
22 changed files with 4874 additions and 320 deletions
+4
View File
@@ -2,3 +2,7 @@
/dist /dist
*.sanctum *.sanctum
*.log *.log
.env
.env.*
*.token
.token
+30 -1
View File
@@ -5,7 +5,36 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [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 ## [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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -115,7 +115,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -136,6 +136,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -160,6 +166,33 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@@ -175,6 +208,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -273,18 +312,82 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 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]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -394,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -421,6 +524,33 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 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]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -596,6 +726,21 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "htmlescape" name = "htmlescape"
version = "0.3.1" version = "0.3.1"
@@ -896,6 +1041,24 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" 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]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -935,7 +1098,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -944,7 +1107,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -956,6 +1119,35 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -974,6 +1166,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "padlock"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c10569378a1dacd9f30dbe7ae49e054d2c45dc2f8ee49899903e09c3924e8b6f"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1116,7 +1314,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.1",
] ]
[[package]] [[package]]
@@ -1144,7 +1342,7 @@ checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [ dependencies = [
"libc", "libc",
"rtoolbox", "rtoolbox",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1154,7 +1352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1163,7 +1361,7 @@ version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.1",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@@ -1179,19 +1377,22 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "sanctum" name = "sanctum"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
"argon2", "argon2",
"bip39",
"bytes", "bytes",
"clap", "clap",
"dav-server", "dav-server",
"dyn-clone", "dyn-clone",
"futures-util", "futures-util",
"hex",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"lz4_flex",
"rand", "rand",
"rpassword", "rpassword",
"rusqlite", "rusqlite",
@@ -1201,6 +1402,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tray-item",
"zeroize", "zeroize",
] ]
@@ -1308,7 +1510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1401,6 +1603,21 @@ dependencies = [
"zerovec", "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]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@@ -1415,7 +1632,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1504,6 +1721,28 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -1522,6 +1761,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "universal-hash" name = "universal-hash"
version = "0.5.1" version = "0.5.1"
@@ -1695,6 +1943,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1704,6 +1961,70 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+5 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "sanctum" name = "sanctum"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"] authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -33,6 +33,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
dyn-clone = "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] [profile.release]
opt-level = 3 opt-level = 3
+132 -33
View File
@@ -1,42 +1,58 @@
<p align="center">
<img src="assets/logo.png" alt="Sanctum Logo" width="220"/>
</p>
# Sanctum 🛡️ # 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** 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.
- **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`) - **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
- **Statisches Single-Binary** (`sanctum.exe`, ~4.6 MB) ohne externe DLL-Abhängigkeiten - **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` (~4.6 MB) ohne externe DLL-Abhängigkeiten.
--- ---
## 🔐 Kryptografie & Sicherheitsarchitektur ## 🔐 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. 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)**: - **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. 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**: - **Speichersicherheit (Zeroize)**:
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im Arbeitsspeicher sicher zu nullen. 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)**: - **Chunk-Verschlüsselung (AES-256-GCM)**:
Dateien werden in Blöcken von 1 MB verschlüsselt. Dateien werden in Blöcken von 1 MB verschlüsselt.
- **Schutz vor Swap-Angriffen**: - **Swap-Attack-Schutz**:
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. 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 journal_mode = WAL;`
- `PRAGMA synchronous = NORMAL;` - `PRAGMA synchronous = NORMAL;`
- `PRAGMA page_size = 8192;` - `PRAGMA page_size = 8192;`
- `PRAGMA foreign_keys = ON;` - **LZ4-Kompression**:
- **Tabellen**: 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.
- `meta`: Container-Header mit Magic Bytes (`SANCTUM\0`), Version 1, Salt, KDF-Parametern und Wrapped DEK. - **Speicherplatzrückgabe**:
- `nodes`: Verzeichnis- und Dateiknoten mit Hierarchiebaum und Zeitstempeln. 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.
- `chunks`: Verschlüsselte Nutzdatenblöcke mit Nonce und Authentifizierungstag.
--- ---
@@ -52,37 +68,119 @@ Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
--- ---
## 🛠️ Verwendung ## 🛠️ CLI-Referenz & Verwendung
### 1. Container initialisieren
### 1. Neuen Container anlegen
```powershell ```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 ```powershell
sanctum.exe mount --path "C:\Users\username\Documents\safe.sanctum" --drive S # Automatische Wahl des nächsten freien Laufwerksbuchstabens (z. B. Z:):
``` sanctum.exe mount --path "C:\Pfad\tresor.sanctum"
Das Netzlaufwerk `S:` steht sofort im Windows Explorer zur Verfügung.
Zum Beenden und sicheren Trennen einfach `Ctrl+C` im Terminal drücken. # Bestimmten Laufwerksbuchstaben erzwingen:
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
# Mit Inaktivitäts-Timeout (in Sekunden):
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
```
- **Automatischer Slot-Unlock**: Sanctum prüft das eingegebene Passwort gegen alle Slots und bindet automatisch den entsprechenden Tresor ein (Slot 0 Decoy oder Slot 1 Hidden Vault).
- **Windows Explorer**: Das gemountete Laufwerk wird automatisch im Explorer geöffnet.
- **System-Tray**: Ein Schild-Icon im Windows Infobereich erlaubt Statusabfrage und direktes Aushängen.
- **Beenden**: `Ctrl+C` im Terminal oder Rechtsklick im Tray -> "Aushängen & Beenden" führt einen sauberen Unmount, Speicher-Compaction und WAL-Checkpoint durch.
---
### 3. Container manuell trennen
### 3. Laufwerk manuell trennen
```powershell ```powershell
sanctum.exe unmount --drive S sanctum.exe unmount --drive S
``` ```
--- ---
## 📦 Release Packaging ### 4. Master-Passwort ändern
Um ein vollständiges Release-Paket mit Tests, komprimiertem ZIP-Archiv und SHA-256 Prüfsummen zu erstellen: ```powershell
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
```
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen.
---
### 5. Disaster Recovery & Notfallschlüssel
```powershell
# 24-Wort BIP-39 Notfallschlüssel anzeigen:
sanctum.exe recovery-key --path "C:\Pfad\tresor.sanctum"
# Konsistentes Online-Backup erstellen (auch während Mount möglich):
sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "D:\Backup\tresor_backup.sanctum"
# Container aus Backup wiederherstellen:
sanctum.exe restore --path "D:\Backup\tresor_backup.sanctum" --output "C:\Pfad\tresor_restored.sanctum"
# Vollständige Integritätsprüfung (B-Tree, Knoten und AEAD-Tags aller Chunks):
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
```
---
### 6. Storage Compaction (Speicherbereinigung)
```powershell
# Freelist-Pages an Windows freigeben und Container verkleinern:
sanctum.exe compact --path "C:\Pfad\tresor.sanctum"
# Nur eine begrenzte Anzahl Pages freigeben:
sanctum.exe compact --path "C:\Pfad\tresor.sanctum" --pages 500
```
---
### 7. Windows Explorer Kontextmenü-Integration
```powershell
# .sanctum-Dateien für Rechtsklick ("In Sanctum öffnen") in HKCU registrieren (ohne Adminrechte):
sanctum.exe register
# Registrierung wieder rückstandslos entfernen:
sanctum.exe unregister
```
---
## 🛡️ OpSec & Explorer Anti-Leak Shield
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
1. **Anti-Leak Dateifilter**: Unterdrückt das Anlegen von `Thumbs.db`, `desktop.ini`, Office-Sperrdateien (`~$*`), temporären Dateien (`*.tmp`) und NTFS Alternate Data Streams (`:Zone.Identifier`).
2. **Inactivity Auto-Lock**: Erkennt Inaktivität anhand echter Lese-/Schreibzugriffe und trennt den Container automatisch nach Erreichen des Timeouts.
3. **Session Lock Detection**: Reagiert über `WTSRegisterSessionNotification` sofort auf Windows-Sitzungssperren (`Win + L`) oder Abmeldungen und schließt den Container blitzschnell ab.
---
## 📦 Release Packaging & Distribution
Um ein Distributionspaket mit Release-Binary, Checksummen und Dokumentation zu bauen:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
``` ```
Das fertige Paket liegt in `dist/` bereit: Erzeugt:
- `dist/sanctum-v0.1.0-windows-x86_64.zip` - `dist/sanctum-v0.2.0-windows-x86_64.zip`
- `dist/SHA256SUMS.txt` - `dist/SHA256SUMS.txt`
--- ---
@@ -91,3 +189,4 @@ Das fertige Paket liegt in `dist/` bereit:
- Lizenziert unter der [MIT License](LICENSE). - Lizenziert unter der [MIT License](LICENSE).
- Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md). - Details zu allen Versionen und Änderungen findest du im [CHANGELOG.md](CHANGELOG.md).
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);
}
}
}
}
}
}
+1
View File
@@ -65,6 +65,7 @@ Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md") Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE") Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md") Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") (Join-Path $StagingDir "CHANGELOG.md")
Copy-Item (Join-Path $ProjectRoot "assets") (Join-Path $StagingDir "assets") -Recurse
# ZIP-Archiv schnueren # ZIP-Archiv schnueren
Compress-Archive -Path "$StagingDir\*" -DestinationPath $ZipFile -Force Compress-Archive -Path "$StagingDir\*" -DestinationPath $ZipFile -Force
+130
View File
@@ -0,0 +1,130 @@
param (
[string]$Token,
[string]$GiteaUrl = "https://gitea.pansi.eu",
[string]$Owner = "harald",
[string]$Repo = "sanctum"
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $matches[1]
} else {
Write-Error "Version nicht gefunden."
exit 1
}
$TagName = "v$Version"
$DistDir = Join-Path $ProjectRoot "dist"
$ZipFile = Join-Path $DistDir "sanctum-$TagName-windows-x86_64.zip"
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
if (-not (Test-Path $ZipFile)) {
Write-Host "Dist-Paket existiert noch nicht. Baue Paket..." -ForegroundColor Yellow
& (Join-Path $ScriptDir "package-release.ps1")
}
# .env-Datei laden falls vorhanden (ist in .gitignore)
$EnvFile = Join-Path $ProjectRoot ".env"
if (Test-Path $EnvFile) {
Get-Content $EnvFile | ForEach-Object {
if ($_ -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$') {
$val = $matches[2].Trim('"', "'")
[System.Environment]::SetEnvironmentVariable($matches[1], $val, "Process")
}
}
}
# Token ermitteln (Parameter -> Environment -> Prompt)
if (-not $Token) {
if ($env:GITEA_TOKEN) {
$Token = $env:GITEA_TOKEN
} else {
$SecureToken = Read-Host "Gitea Personal Access Token (mit 'repo'-Scope)" -AsSecureString
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureToken)
$Token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
}
}
if (-not $Token -or $Token.Trim().Length -eq 0) {
Write-Error "Kein Token angegeben. Abgebrochen."
exit 1
}
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " Veroeffentliche Release $TagName auf Gitea" -ForegroundColor Cyan
Write-Host " Repository: $Owner/$Repo ($GiteaUrl)" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
$Headers = @{
"Authorization" = "token $Token"
"Accept" = "application/json"
}
# Pruefen ob Release bereits existiert
$ExistingReleases = try {
Invoke-RestMethod -Uri "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases" -Headers $Headers -Method Get
} catch {
@()
}
$Release = $ExistingReleases | Where-Object { $_.tag_name -eq $TagName }
if (-not $Release) {
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$Changelog = [System.IO.File]::ReadAllText((Join-Path $ProjectRoot "CHANGELOG.md"), $Utf8NoBom)
$PayloadPath = Join-Path $DistDir "release_req.json"
$Payload = @{
tag_name = $TagName
name = "Sanctum $TagName (Windows x86_64)"
body = $Changelog
draft = $false
prerelease = $false
} | ConvertTo-Json -Depth 5
[System.IO.File]::WriteAllText($PayloadPath, $Payload, $Utf8NoBom)
$CreateResp = & curl.exe -s -X POST "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases" `
-H "Authorization: token $Token" `
-H "Content-Type: application/json; charset=utf-8" `
--data-binary "@$PayloadPath"
Remove-Item $PayloadPath -Force -ErrorAction SilentlyContinue
$Release = $CreateResp | ConvertFrom-Json
if (-not $Release.id) {
Write-Error "Fehler beim Erstellen des Releases: $CreateResp"
exit 1
}
Write-Host "[OK] Release $TagName auf Gitea angelegt (ID: $($Release.id))." -ForegroundColor Green
} else {
Write-Host "[INFO] Release $TagName existiert bereits (ID: $($Release.id))." -ForegroundColor Yellow
}
$UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets"
# ZIP Asset hochladen
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan
$ZipResult = & curl.exe -s -X POST "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip" `
-H "Authorization: token $Token" `
-F "attachment=@$ZipFile"
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green
# SHA256SUMS.txt hochladen
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
$SumResult = & curl.exe -s -X POST "$UploadUrl?name=SHA256SUMS.txt" `
-H "Authorization: token $Token" `
-F "attachment=@$ChecksumFile"
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
Write-Host "`n============================================================" -ForegroundColor Green
Write-Host " Release $TagName erfolgreich auf Gitea veroeffentlicht!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " URL: $GiteaUrl/$Owner/$Repo/releases/tag/$TagName`n"
+263 -8
View File
@@ -10,9 +10,15 @@ use serde::{Deserialize, Serialize};
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0"; pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION: 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 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_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
pub const DEFAULT_TIME_COST: u32 = 3; pub const DEFAULT_TIME_COST: u32 = 3;
pub const DEFAULT_PARALLELISM: u32 = 4; pub const DEFAULT_PARALLELISM: u32 = 4;
@@ -123,6 +129,101 @@ pub fn unwrap_dek(
Ok(dek) Ok(dek)
} }
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter KDF/DEK-Slot. 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; 32];
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)
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind.
pub fn encrypt_node_name(dek: &[u8; 32], 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 tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer)
.expect("Name encryption");
format!(
"$h${}${}${}",
hex::encode(nonce_bytes),
hex::encode(tag.as_slice()),
hex::encode(&buffer)
)
}
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
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()?;
let mut buffer = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
}
}
}
}
None
}
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
let mnemonic = bip39::Mnemonic::from_entropy(dek)
.map_err(|e| anyhow::anyhow!("Fehler beim Erzeugen der BIP-39 Notfallphrase: {e}"))?;
Ok(mnemonic.to_string())
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let cleaned = phrase
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
let entropy = mnemonic.to_entropy();
if entropy.len() != 32 {
bail!(
"Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}",
entropy.len()
);
}
let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&entropy);
Ok(dek)
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern: /// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian). /// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline] #[inline]
@@ -134,12 +235,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. /// 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. /// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk( pub fn encrypt_chunk(
dek: &[u8; 32], dek: &[u8; 32],
node_id: i64, node_id: i64,
chunk_index: u32, chunk_index: u32,
plaintext: &[u8], plaintext: &[u8],
format_version: u32,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek) let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -150,7 +254,28 @@ pub fn encrypt_chunk(
let aad = build_chunk_aad(node_id, chunk_index); 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 tatsächlich Bytes gespart werden (+1 Byte für das Flag)
if compressed.len() + 1 < 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 let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut buffer) .encrypt_in_place_detached(nonce, &aad, &mut buffer)
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?; .map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
@@ -162,6 +287,7 @@ pub fn encrypt_chunk(
} }
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM. /// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2).
pub fn decrypt_chunk( pub fn decrypt_chunk(
dek: &[u8; 32], dek: &[u8; 32],
node_id: i64, node_id: i64,
@@ -169,6 +295,7 @@ pub fn decrypt_chunk(
ciphertext: &[u8], ciphertext: &[u8],
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
format_version: u32,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek) let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -182,9 +309,25 @@ pub fn decrypt_chunk(
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag) .decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?; .map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
if format_version >= FORMAT_VERSION_V2 {
if buffer.is_empty() {
return Ok(Vec::new());
}
match buffer[0] {
COMPRESSION_NONE => Ok(buffer[1..].to_vec()),
COMPRESSION_LZ4 => {
let decompressed = lz4_flex::decompress_size_prepended(&buffer[1..])
.map_err(|e| anyhow::anyhow!("LZ4-Dekomprimierungsfehler im Chunk: {e}"))?;
Ok(decompressed)
}
other => bail!("Unbekannte Chunk-Kompressionsmethode: 0x{:02x}", other),
}
} else {
Ok(buffer) Ok(buffer)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -226,23 +369,135 @@ mod tests {
let node_id = 42i64; let node_id = 42i64;
let chunk_index = 0u32; 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 // Reguläre Entschlüsselung (v2)
let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag).unwrap(); let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben) // Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
let swap_node_err = 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()); assert!(swap_node_err.is_err());
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben) // Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
let swap_idx_err = 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()); assert!(swap_idx_err.is_err());
// Manipulation des Ciphertexts // Manipulation des Ciphertexts
let mut tampered_ct = ciphertext.clone(); let mut tampered_ct = ciphertext.clone();
tampered_ct[0] ^= 0x01; tampered_ct[0] ^= 0x01;
assert!(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_v1_backward_compatibility() {
let dek = generate_dek();
let plaintext = b"Uncompressed Legacy V1 Chunk Payload";
let node_id = 5i64;
let chunk_index = 0u32;
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
let (ciphertext, nonce, tag) =
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
assert_eq!(ciphertext.len(), plaintext.len());
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_bip39_recovery_phrase_roundtrip() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic");
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
assert_eq!(*dek, *recovered_messy);
}
#[test]
fn test_bip39_invalid_words_and_checksum() {
// 1. Nicht im Wörterbuch enthaltenes Wort
let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword";
assert!(mnemonic_to_dek(invalid_word_phrase).is_err());
// 2. Falsche Wortanzahl (z. B. 23 statt 24)
let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow";
assert!(mnemonic_to_dek(short_phrase).is_err());
// 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert)
let dek = generate_dek();
let mut words: Vec<String> = dek_to_mnemonic(&dek)
.unwrap()
.split_whitespace()
.map(|s| s.to_string())
.collect();
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
let original_last = words[23].clone();
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
let corrupted_phrase = words.join(" ");
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
}
#[test]
fn test_hidden_node_name_encryption_and_dummy_slot() {
let dek = generate_dek();
let filename = "ultra_geheimes_dokument.pdf";
let encrypted = encrypt_node_name(&dek, filename);
assert!(encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename));
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename);
// Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
// Dummy-Slot hat korrekte Längen
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
assert_eq!(dummy_dek.len(), 32);
assert_eq!(dummy_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.len(), 16);
} }
} }
+3
View File
@@ -1,5 +1,8 @@
pub mod crypto; pub mod crypto;
pub mod mount; pub mod mount;
pub mod recovery;
pub mod storage; pub mod storage;
pub mod ui; pub mod ui;
pub mod verify;
pub mod vfs; pub mod vfs;
pub mod windows;
+629 -13
View File
@@ -4,10 +4,17 @@ use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams}; use sanctum::crypto::{
use sanctum::mount::{format_drive, mount_container, unmount_drive}; dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek,
wrap_dek, KdfParams, FORMAT_VERSION,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
export_header_backup, restore_header_backup, restore_header_from_recovery_key,
};
use sanctum::storage::Database; use sanctum::storage::Database;
use sanctum::ui; use sanctum::ui;
use sanctum::verify::verify_container;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "sanctum")] #[command(name = "sanctum")]
@@ -26,6 +33,21 @@ enum Commands {
/// Pfad zur zu erstellenden .sanctum Containerdatei /// Pfad zur zu erstellenden .sanctum Containerdatei
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, path: PathBuf,
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)]
with_hidden: bool,
},
/// 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 /// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein
@@ -34,13 +56,37 @@ enum Commands {
#[arg(short, long)] #[arg(short, long)]
path: PathBuf, 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)] #[arg(short, long)]
drive: String, drive: Option<String>,
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443) /// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
#[arg(long)] #[arg(long)]
port: Option<u16>, port: Option<u16>,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
#[arg(long)]
recovery_key: Option<String>,
/// Öffnet das Netzlaufwerk nach dem Mounten nicht automatisch im Windows Explorer
#[arg(long, default_value_t = false)]
no_open: bool,
/// Deaktiviert das Windows System-Tray Icon während des Mounts
#[arg(long, default_value_t = false)]
no_tray: bool,
/// Automatisches Aushängen und Sperren nach N Sekunden Inaktivität (z. B. 300 für 5 Minuten)
#[arg(long, value_name = "SECS")]
idle_timeout: Option<u64>,
/// Verhindert das automatische Sperren beim Sperren des Windows-Bildschirms (Win + L)
#[arg(long, default_value_t = false)]
no_screen_lock: bool,
/// Deaktiviert das Blockieren und Verbergen von Windows Explorer Metadaten (Thumbs.db, desktop.ini)
#[arg(long, default_value_t = false)]
no_anti_leak: bool,
}, },
/// Trennt ein eingebundenes Netzlaufwerk manuell /// Trennt ein eingebundenes Netzlaufwerk manuell
@@ -49,6 +95,67 @@ enum Commands {
#[arg(short, long)] #[arg(short, long)]
drive: String, drive: String,
}, },
/// Ändert das Master-Passwort eines bestehenden Sanctum-Containers
Passwd {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort)
#[arg(long)]
recovery_key: Option<String>,
},
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
BackupHeader {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Optionaler Ausgabepfad (Standard: <CONTAINER>.hdr)
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Stellt den Container-Header aus einem Backup oder via 24-Wort Notfallschlüssel wieder her
RestoreHeader {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Header-Backup-Datei (.sanctum.hdr)
#[arg(long)]
header_file: Option<PathBuf>,
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
#[arg(long)]
recovery_key: Option<String>,
},
/// 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> { fn parse_drive_letter(s: &str) -> Result<char> {
@@ -65,7 +172,7 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase()) Ok(ch.to_ascii_uppercase())
} }
fn handle_init(container_path: &Path) -> Result<()> { fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
if container_path.exists() { if container_path.exists() {
bail!( bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
@@ -77,8 +184,103 @@ fn handle_init(container_path: &Path) -> Result<()> {
println!("│ Sanctum — Neuen verschlüsselten Container erstellen │"); println!("│ Sanctum — Neuen verschlüsselten Container erstellen │");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display()); println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault"));
}
println!(); println!();
if with_hidden {
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
let password_0 = 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 = 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 = 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 = 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 beide DEKs unabhängig via AES-256-GCM...");
let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_hidden(
&salt_0,
&kdf_params_0,
&wrapped_dek_0,
&nonce_0,
&tag_0,
Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_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 erfolgreich initialisiert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!(" • Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung");
println!(" • Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie");
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 = rpassword::prompt_password("Master-Passwort eingeben: ") let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?; .context("Fehler beim Einlesen des Passworts")?;
@@ -117,20 +319,350 @@ fn handle_init(container_path: &Path) -> Result<()> {
db.checkpoint() db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?; .context("Fehler beim finalen WAL-Checkpoint")?;
let recovery_phrase =
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │"); println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(); println!();
println!(" • Container: {}", container_path.display()); 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!(" • 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!();
println!(" Befehl zum Einbinden als Netzlaufwerk:"); 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!(); 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) = if let Some(phrase) = recovery_key {
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0)
} else {
let old_password = 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...");
let mut found = None;
for slot in &meta.slots {
if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(
&old_kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
found = Some((d, slot.slot_id));
break;
}
}
}
found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
};
println!();
let new_password = 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 = 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) =
wrap_dek(&new_kek, &dek).context("DEK-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_restore_header(
container_path: &Path,
header_file: Option<&Path>,
recovery_key: Option<&str>,
) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Header wiederherstellen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
if let Some(hdr) = header_file {
println!(" Verwende Backup-Datei: {}", hdr.display());
ui::step(1, 2, "📦", "Lese und validiere Header-Backup...");
restore_header_backup(container_path, hdr)?;
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
} else if let Some(key) = recovery_key {
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel...");
let new_password = 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 = 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_header_from_recovery_key(container_path, key, &new_password)?;
ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!");
println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
} else {
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
}
println!();
Ok(())
}
fn handle_recovery_key(container_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — 24-Wort Notfallschlüssel anzeigen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
let password = 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 mut found = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some((dek, slot.slot_id));
break;
}
}
}
let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
println!(" • Vault: {}", ui::magenta("Hidden Vault (Slot 1)"));
}
ui::print_recovery_phrase_card(&phrase);
Ok(())
}
fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Integritätsprüfung (FSCK) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
let password = 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")?;
let mut found = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some(d);
break;
}
}
}
match found {
Some(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
Some(d)
}
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(()) Ok(())
} }
@@ -138,19 +670,63 @@ async fn run() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Init { path } => { Commands::Init { path, with_hidden } => {
handle_init(&path)?; handle_init(&path, with_hidden)?;
} }
Commands::Mount { path, drive, port } => { Commands::Compact { path, pages } => {
let drive_char = parse_drive_letter(&drive)?; handle_compact(&path, pages)?;
}
Commands::Mount {
path,
drive,
port,
recovery_key,
no_open,
no_tray,
idle_timeout,
no_screen_lock,
no_anti_leak,
} => {
let drive_char = match drive {
Some(ref d) => parse_drive_letter(d)?,
None => {
let auto_drive = sanctum::windows::find_next_available_drive()?;
println!(
" {} Kein Laufwerksbuchstabe angegeben. Wähle automatisch freien Buchstaben {}:",
ui::cyan(""),
auto_drive
);
auto_drive
}
};
let auth = if let Some(key) = recovery_key {
ContainerAuth::RecoveryKey(key)
} else {
let prompt_text = format!( let prompt_text = format!(
"Master-Passwort für Container '{}' eingeben: ", "Master-Passwort für Container '{}' eingeben: ",
path.display() path.display()
); );
let password = rpassword::prompt_password(prompt_text) let password = rpassword::prompt_password(prompt_text)
.context("Fehler beim Einlesen des Passworts")?; .context("Fehler beim Einlesen des Passworts")?;
ContainerAuth::Password(password)
};
mount_container(&path, drive_char, port, &password).await?; let open_explorer = !no_open;
let enable_tray = !no_tray;
let lock_on_screen_lock = !no_screen_lock;
let anti_leak = !no_anti_leak;
mount_container(
&path,
drive_char,
port,
auth,
open_explorer,
enable_tray,
idle_timeout,
lock_on_screen_lock,
anti_leak,
)
.await?;
} }
Commands::Unmount { drive } => { Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?; let drive_char = parse_drive_letter(&drive)?;
@@ -161,11 +737,51 @@ async fn run() -> Result<()> {
println!("{}", ui::green("OK")); println!("{}", ui::green("OK"));
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green(""), drive_str); 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,
} => {
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref())?;
}
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(()) Ok(())
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren // Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
+186 -19
View File
@@ -2,6 +2,8 @@ use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use dav_server::{fakels::FakeLs, DavHandler}; use dav_server::{fakels::FakeLs, DavHandler};
@@ -12,11 +14,18 @@ use tokio::net::TcpListener;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::crypto::{derive_kek, unwrap_dek}; use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek};
use crate::storage::Database; use crate::storage::Database;
use crate::ui; use crate::ui;
use crate::vfs::SanctumFs; 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(String),
RecoveryKey(String),
}
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
pub fn format_drive(drive_letter: char) -> String { pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase()) format!("{}:", drive_letter.to_ascii_uppercase())
@@ -73,7 +82,12 @@ pub async fn mount_container(
container_path: &Path, container_path: &Path,
drive_letter: char, drive_letter: char,
requested_port: Option<u16>, requested_port: Option<u16>,
password: &str, auth: ContainerAuth,
open_explorer: bool,
enable_tray: bool,
idle_timeout: Option<u64>,
lock_on_screen_lock: bool,
anti_leak: bool,
) -> Result<()> { ) -> Result<()> {
let drive_str = format_drive(drive_letter); let drive_str = format_drive(drive_letter);
@@ -93,21 +107,57 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .context("Konnte Container-Header nicht lesen")?;
let (dek, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab..."); ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params) let mut unwrapped = None;
.context("Schlüsselableitung fehlgeschlagen")?;
ui::step(3, 4, "🔓", "Entschlüssele DEK via AES-256-GCM..."); for slot in &meta.slots {
let dek = unwrap_dek( if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(
&kek, &kek,
&meta.wrapped_dek, &slot.wrapped_dek,
&meta.header_nonce, &slot.header_nonce,
&meta.header_tag, &slot.header_tag,
) ) {
.context("Ungültiges Master-Passwort oder Container beschädigt")?; unwrapped = Some((dek, slot.version, slot.slot_id));
break;
}
}
}
// WebDAV Filesystem und Handler konfigurieren match unwrapped {
let fs = SanctumFs::new(db.clone(), dek); Some((dek, ver, slot_id)) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id)
}
None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt");
}
}
}
ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
let vault_id = if db.has_hidden_vault().unwrap_or(false) {
let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty()
};
if is_hidden { 1 } else { 0 }
} else {
0
};
(dek, meta.version, vault_id)
}
};
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing)
let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id);
let last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
.locksystem(FakeLs::new()) .locksystem(FakeLs::new())
@@ -188,6 +238,83 @@ pub async fn mount_container(
return Err(e); return Err(e);
} }
// Optional automatisch im Windows Explorer öffnen
if open_explorer {
let _ = crate::windows::open_in_explorer(drive_letter);
}
// System-Tray Initialisierung
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _tray = if enable_tray {
let icon_source = crate::windows::get_default_system_icon()
.unwrap_or(tray_item::IconSource::Resource(""));
let title = format!("Sanctum ({drive_str})");
match tray_item::TrayItem::new(&title, icon_source) {
Ok(mut tray) => {
let container_name = container_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let _ = tray.add_label(&format!("Sanctum: {drive_str} ({container_name})"));
let dl = drive_letter;
let _ = tray.add_menu_item("Im Explorer öffnen", move || {
let _ = crate::windows::open_in_explorer(dl);
});
let s_tx = tray_shutdown_tx.clone();
let _ = tray.add_menu_item("Trennen & Beenden", move || {
let _ = s_tx.blocking_send(());
});
Some(tray)
}
Err(e) => {
debug!("System-Tray Icon konnte nicht erstellt werden: {e}");
None
}
}
} else {
None
};
// Inaktivitäts-Timer (Auto-Lock)
let (idle_shutdown_tx, mut idle_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
if let Some(timeout_secs) = idle_timeout {
if timeout_secs > 0 {
let last_act = last_activity.clone();
let idle_tx = idle_shutdown_tx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
interval.tick().await;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let last = last_act.load(Ordering::Relaxed);
if now.saturating_sub(last) >= timeout_secs {
let _ = idle_tx.send(()).await;
break;
}
}
});
}
}
// Windows-Sitzungssperre (Win + L Auto-Lock)
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
let _session_monitor = if lock_on_screen_lock {
match crate::windows::start_session_lock_monitor(session_lock_tx) {
Ok(guard) => Some(guard),
Err(e) => {
warn!("Konnte Windows Session-Lock Monitor nicht aktivieren: {e}");
None
}
}
} else {
None
};
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Container erfolgreich gemountet │"); println!("│ ✔ Sanctum Container erfolgreich gemountet │");
@@ -196,17 +323,45 @@ pub async fn mount_container(
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); 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:{}/", bound_port);
if vault_id == 1 {
println!(" • Vault: {} (Verschlüsselte Dateinamen)", ui::magenta("Hidden Vault (Slot 1)"));
}
if let Some(secs) = idle_timeout {
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
}
if lock_on_screen_lock {
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
}
if anti_leak {
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
}
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
println!(); println!();
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!(); println!();
// Warten auf Strg+C // Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L)
tokio::signal::ctrl_c() tokio::select! {
.await res = tokio::signal::ctrl_c() => {
.context("Fehler beim Registrieren des Ctrl+C Signalhandlers")?; let _ = res;
println!(); println!();
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]")); println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
}
_ = tray_shutdown_rx.recv() => {
println!();
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
}
_ = session_lock_rx.recv() => {
println!();
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
_ = idle_shutdown_rx.recv() => {
println!();
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
}
}
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
let _ = std::io::Write::flush(&mut std::io::stdout()); let _ = std::io::Write::flush(&mut std::io::stdout());
@@ -221,6 +376,18 @@ pub async fn mount_container(
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
let _ = server_handle.await; 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 // SQLite WAL Checkpoint erzwingen
print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]")); print!(" {} Führe SQLite WAL-Checkpoint aus ... ", ui::dim("[-]"));
let _ = std::io::Write::flush(&mut std::io::stdout()); let _ = std::io::Write::flush(&mut std::io::stdout());
+323
View File
@@ -0,0 +1,323 @@
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, mnemonic_to_dek, wrap_dek, 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 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,
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);
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),
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 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,
};
Ok(ContainerMeta {
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
})
}
}
/// 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 den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels
/// und initialisiert ein neues Master-Passwort.
pub fn restore_header_from_recovery_key(
container_path: &Path,
recovery_key: &str,
new_password: &str,
) -> 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")?;
// 3. DEK mit neuem KEK wrappen
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?;
let slot0 = SlotMeta {
slot_id: 0,
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
let meta = ContainerMeta {
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params,
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
};
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{generate_dek, generate_salt, unwrap_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);
}
}
+840 -86
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...` /// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...`
pub fn step(num: u8, total: u8, icon: &str, msg: &str) { pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
let tag = cyan(&format!("[{}/{}]", num, total)); let tag = cyan(&format!("[{}/{}]", num, total));
println!(" {} {} {}", tag, icon, msg); 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!();
}
+343
View File
@@ -0,0 +1,343 @@
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)
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());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes {
if node.id == 1 {
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);
}
}
// 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) = dek {
if 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, 2); // Root + 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);
}
}
+365 -72
View File
@@ -1,5 +1,6 @@
use std::fmt::Debug; use std::fmt::Debug;
use std::io::SeekFrom; use std::io::SeekFrom;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -18,6 +19,31 @@ use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord}; use crate::storage::{Database, NodeRecord};
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache-
/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig
/// im Container blockiert und verborgen werden (Anti-Leak Shield).
pub fn is_leak_file(filename: &str) -> bool {
let lower = filename.trim().to_ascii_lowercase();
match lower.as_str() {
"thumbs.db"
| "ehthumbs.db"
| "ehthumbs_vista.db"
| "desktop.ini"
| "folder.jpg"
| "albumartsmall.jpg"
| "autorun.inf"
| ".ds_store" => true,
_ => {
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
{
true
} else {
false
}
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Metadaten // Metadaten
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -86,6 +112,8 @@ pub struct SanctumFile {
meta: SanctumMetaData, meta: SanctumMetaData,
// (chunk_index, decrypted_payload, is_dirty) // (chunk_index, decrypted_payload, is_dirty)
cached_chunk: Option<(u32, Vec<u8>, bool)>, cached_chunk: Option<(u32, Vec<u8>, bool)>,
format_version: u32,
last_activity: Arc<AtomicU64>,
} }
impl Debug for SanctumFile { impl Debug for SanctumFile {
@@ -94,6 +122,7 @@ impl Debug for SanctumFile {
.field("node_id", &self.node_id) .field("node_id", &self.node_id)
.field("file_size", &self.file_size) .field("file_size", &self.file_size)
.field("cursor", &self.cursor) .field("cursor", &self.cursor)
.field("format_version", &self.format_version)
.finish() .finish()
} }
} }
@@ -103,6 +132,8 @@ impl SanctumFile {
node: NodeRecord, node: NodeRecord,
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
last_activity: Arc<AtomicU64>,
) -> Self { ) -> Self {
let meta = SanctumMetaData { let meta = SanctumMetaData {
is_dir: node.is_dir, is_dir: node.is_dir,
@@ -119,14 +150,25 @@ impl SanctumFile {
dek, dek,
meta, meta,
cached_chunk: None, cached_chunk: None,
format_version,
last_activity,
} }
} }
fn touch(&self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_activity.store(now, Ordering::Relaxed);
}
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück.
fn flush_cached_chunk(&mut self) -> Result<(), FsError> { fn flush_cached_chunk(&mut self) -> Result<(), FsError> {
if let Some((idx, ref data, true)) = self.cached_chunk { if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) = let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data).map_err(|e| { encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure FsError::GeneralFailure
})?; })?;
@@ -166,6 +208,7 @@ impl SanctumFile {
&record.ciphertext, &record.ciphertext,
&record.nonce, &record.nonce,
&record.tag, &record.tag,
self.format_version,
) )
.map_err(|e| { .map_err(|e| {
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}"); error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
@@ -205,6 +248,7 @@ impl DavFile for SanctumFile {
} }
fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> { fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> {
self.touch();
Box::pin(async move { Box::pin(async move {
if self.cursor >= self.file_size || count == 0 { if self.cursor >= self.file_size || count == 0 {
return Ok(Bytes::new()); return Ok(Bytes::new());
@@ -247,6 +291,7 @@ impl DavFile for SanctumFile {
} }
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> { fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
self.touch();
Box::pin(async move { Box::pin(async move {
let mut src = &buf[..]; let mut src = &buf[..];
@@ -295,6 +340,7 @@ impl DavFile for SanctumFile {
} }
fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> { fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> {
self.touch();
Box::pin(async move { Box::pin(async move {
let new_cursor = match pos { let new_cursor = match pos {
SeekFrom::Start(offset) => offset as i64, SeekFrom::Start(offset) => offset as i64,
@@ -312,6 +358,7 @@ impl DavFile for SanctumFile {
} }
fn flush(&mut self) -> FsFuture<'_, ()> { fn flush(&mut self) -> FsFuture<'_, ()> {
self.touch();
Box::pin(async move { Box::pin(async move {
self.flush_cached_chunk()?; self.flush_cached_chunk()?;
let now = SystemTime::now() let now = SystemTime::now()
@@ -339,16 +386,67 @@ impl DavFile for SanctumFile {
pub struct SanctumFs { pub struct SanctumFs {
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
last_activity: Arc<AtomicU64>,
} }
impl SanctumFs { impl SanctumFs {
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>) -> Self { pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self {
Self::with_vault(db, dek, format_version, true, 0)
}
pub fn with_options(
db: Database,
dek: Zeroizing<[u8; 32]>,
format_version: u32,
anti_leak: bool,
) -> Self {
Self::with_vault(db, dek, format_version, anti_leak, 0)
}
pub fn with_vault(
db: Database,
dek: Zeroizing<[u8; 32]>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self { Self {
db, db,
dek: Arc::new(dek), dek: Arc::new(dek),
format_version,
anti_leak,
vault_id,
last_activity: Arc::new(AtomicU64::new(now)),
} }
} }
pub fn vault_id(&self) -> u32 {
self.vault_id
}
pub fn last_activity(&self) -> Arc<AtomicU64> {
self.last_activity.clone()
}
pub fn is_anti_leak_enabled(&self) -> bool {
self.anti_leak
}
pub fn touch(&self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_activity.store(now, Ordering::Relaxed);
}
fn path_to_str(path: &DavPath) -> String { fn path_to_str(path: &DavPath) -> String {
String::from_utf8_lossy(path.as_bytes()).to_string() String::from_utf8_lossy(path.as_bytes()).to_string()
} }
@@ -360,6 +458,33 @@ impl SanctumFs {
None => ("", trimmed), None => ("", trimmed),
} }
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>, FsError> {
self.db
.list_children_in_vault(parent_id, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| {
error!("Fehler beim Erstellen des Knotens '{}': {e}", name);
FsError::GeneralFailure
})
}
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
} }
impl DavFileSystem for SanctumFs { impl DavFileSystem for SanctumFs {
@@ -370,12 +495,28 @@ impl DavFileSystem for SanctumFs {
) -> FsFuture<'a, Box<dyn DavFile>> { ) -> FsFuture<'a, Box<dyn DavFile>> {
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
if self.anti_leak && is_leak_file(file_name) {
if options.create
|| options.create_new
|| options.write
|| options.append
|| options.truncate
{
debug!(
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
file_name
);
return Err(FsError::Forbidden);
}
}
self.touch();
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options); debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
let existing_node = self let existing_node = self.resolve_path(&path_str)?;
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?;
let node = match existing_node { let node = match existing_node {
Some(n) => { Some(n) => {
@@ -409,30 +550,28 @@ impl DavFileSystem for SanctumFs {
} }
None => { None => {
if options.create || options.create_new { if options.create || options.create_new {
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
let parent = self let parent = self
.db .resolve_path(parent_path)?
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !parent.is_dir { if !parent.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
self.db self.create_node(parent.id, file_name, false)?
.create_node(parent.id, file_name, false)
.map_err(|e| {
error!("Fehler beim Erstellen der Datei: {e}");
FsError::GeneralFailure
})?
} else { } else {
return Err(FsError::NotFound); return Err(FsError::NotFound);
} }
} }
}; };
let file = SanctumFile::new(node, self.db.clone(), self.dek.clone()); let file = SanctumFile::new(
node,
self.db.clone(),
self.dek.clone(),
self.format_version,
self.last_activity.clone(),
);
Ok(Box::new(file) as Box<dyn DavFile>) Ok(Box::new(file) as Box<dyn DavFile>)
}) })
} }
@@ -443,24 +582,21 @@ impl DavFileSystem for SanctumFs {
_meta: ReadDirMeta, _meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !node.is_dir { if !node.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
let children = self let children = self.list_children(node.id)?;
.db
.list_children(node.id)
.map_err(|_| FsError::GeneralFailure)?;
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
.into_iter() .into_iter()
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
.map(|child| { .map(|child| {
Ok(Box::new(SanctumDirEntry { Ok(Box::new(SanctumDirEntry {
name: child.name, name: child.name,
@@ -481,10 +617,11 @@ impl DavFileSystem for SanctumFs {
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let meta = SanctumMetaData { let meta = SanctumMetaData {
@@ -504,30 +641,27 @@ impl DavFileSystem for SanctumFs {
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if self let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
.db
.resolve_path(&path_str) if self.anti_leak && is_leak_file(dir_name) {
.map_err(|_| FsError::GeneralFailure)? return Err(FsError::Forbidden);
.is_some() }
{
if self.resolve_path(&path_str)?.is_some() {
return Err(FsError::Exists); return Err(FsError::Exists);
} }
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
let parent = self let parent = self
.db .resolve_path(parent_path)?
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !parent.is_dir { if !parent.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
self.db self.create_node(parent.id, dir_name, true)?;
.create_node(parent.id, dir_name, true)
.map_err(|_| FsError::GeneralFailure)?;
Ok(()) Ok(())
}) })
@@ -535,17 +669,17 @@ impl DavFileSystem for SanctumFs {
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !node.is_dir { if !node.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
if node.id == 1 { let root_id = Database::get_root_node_id_for_vault(self.vault_id);
if node.id == root_id {
// Root-Verzeichnis darf nicht gelöscht werden // Root-Verzeichnis darf nicht gelöscht werden
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -560,11 +694,10 @@ impl DavFileSystem for SanctumFs {
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move { Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let node = self let node = self
.db .resolve_path(&path_str)?
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if node.is_dir { if node.is_dir {
@@ -585,20 +718,22 @@ impl DavFileSystem for SanctumFs {
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
Box::pin(async move { Box::pin(async move {
self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to); let to_str = Self::path_to_str(to);
let node = self let node = self
.db .resolve_path(&from_str)?
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
return Err(FsError::Forbidden);
}
let to_parent = self let to_parent = self
.db .resolve_path(to_parent_path)?
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if !to_parent.is_dir { if !to_parent.is_dir {
@@ -606,11 +741,7 @@ impl DavFileSystem for SanctumFs {
} }
// Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen // Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen
if let Some(dest) = self if let Some(dest) = self.resolve_path(&to_str)? {
.db
.resolve_path(&to_str)
.map_err(|_| FsError::GeneralFailure)?
{
if dest.is_dir { if dest.is_dir {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -619,9 +750,7 @@ impl DavFileSystem for SanctumFs {
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
} }
self.db self.rename_node(node.id, to_parent.id, to_name)?;
.rename_node(node.id, to_parent.id, to_name)
.map_err(|_| FsError::GeneralFailure)?;
Ok(()) Ok(())
}) })
@@ -633,13 +762,12 @@ impl DavFileSystem for SanctumFs {
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
Box::pin(async move { Box::pin(async move {
self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to); let to_str = Self::path_to_str(to);
let node = self let node = self
.db .resolve_path(&from_str)?
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
if node.is_dir { if node.is_dir {
@@ -647,16 +775,16 @@ impl DavFileSystem for SanctumFs {
} }
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
return Err(FsError::Forbidden);
}
let to_parent = self let to_parent = self
.db .resolve_path(to_parent_path)?
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?; .ok_or(FsError::NotFound)?;
let dest_node = self let dest_node = self.create_node(to_parent.id, to_name, false)?;
.db
.create_node(to_parent.id, to_name, false)
.map_err(|_| FsError::GeneralFailure)?;
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!) // Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
let total_chunks = if node.size == 0 { let total_chunks = if node.size == 0 {
@@ -678,11 +806,12 @@ impl DavFileSystem for SanctumFs {
&record.ciphertext, &record.ciphertext,
&record.nonce, &record.nonce,
&record.tag, &record.tag,
self.format_version,
) )
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) = let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext) encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
self.db self.db
@@ -711,3 +840,167 @@ impl DavFileSystem for SanctumFs {
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
use dav_server::fs::OpenOptions;
use futures_util::StreamExt;
#[test]
fn test_is_leak_file() {
assert!(is_leak_file("Thumbs.db"));
assert!(is_leak_file("thumbs.db"));
assert!(is_leak_file("THUMBS.DB"));
assert!(is_leak_file("ehthumbs.db"));
assert!(is_leak_file("ehthumbs_vista.db"));
assert!(is_leak_file("desktop.ini"));
assert!(is_leak_file("Desktop.ini"));
assert!(is_leak_file("Folder.jpg"));
assert!(is_leak_file("albumartsmall.jpg"));
assert!(is_leak_file("AlbumArt_{12345}_Large.jpg"));
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
assert!(is_leak_file("autorun.inf"));
assert!(is_leak_file(".ds_store"));
// Harmlos:
assert!(!is_leak_file("secret.txt"));
assert!(!is_leak_file("passwords.kdbx"));
assert!(!is_leak_file("my_folder.jpg.txt"));
assert!(!is_leak_file("desktop_notes.ini.bak"));
}
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {
let temp_dir = tempfile_placeholder::TempDir::new();
let db_path = temp_dir.path().join("test_vfs.sanctum");
let db = Database::open(&db_path).unwrap();
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek("testpwd", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.unwrap();
let fs = SanctumFs::with_options(db, dek, FORMAT_VERSION, anti_leak);
(fs, temp_dir)
}
mod tempfile_placeholder {
use std::path::{Path, PathBuf};
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new() -> Self {
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
#[tokio::test]
async fn test_anti_leak_blocks_creation() {
let (fs, _dir) = create_test_fs(true);
let path = DavPath::new("/desktop.ini").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
// desktop.ini muss blockiert werden mit Forbidden
let res = fs.open(&path, opts).await;
assert!(matches!(res, Err(FsError::Forbidden)));
// create_dir mit Thumbs.db muss auch blockiert werden
let dir_path = DavPath::new("/Thumbs.db").unwrap();
let res_dir = fs.create_dir(&dir_path).await;
assert!(matches!(res_dir, Err(FsError::Forbidden)));
// Normale Datei muss erlaubt sein
let valid_path = DavPath::new("/notes.txt").unwrap();
let mut valid_opts = OpenOptions::default();
valid_opts.write = true;
valid_opts.create_new = true;
let res_valid = fs.open(&valid_path, valid_opts).await;
assert!(res_valid.is_ok());
}
#[tokio::test]
async fn test_anti_leak_filters_read_dir() {
let (fs_shielded, _dir) = create_test_fs(true);
// Erstelle eine normale Datei
let normal_path = DavPath::new("/legit.txt").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let res = fs_shielded.open(&normal_path, opts).await;
assert!(res.is_ok());
// Erzwinge direkt in die DB eine Thumbs.db Datei
fs_shielded.db.create_node(1, "Thumbs.db", false).unwrap();
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
let root_path = DavPath::new("/").unwrap();
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut names = Vec::new();
while let Some(entry) = stream.next().await {
let entry = entry.unwrap();
names.push(String::from_utf8_lossy(&entry.name()).to_string());
}
assert!(names.contains(&"legit.txt".to_string()));
assert!(!names.contains(&"Thumbs.db".to_string()));
// Mit unshielded FS (anti_leak = false) muss Thumbs.db sichtbar sein
let fs_unshielded = SanctumFs::with_options(
fs_shielded.db.clone(),
zeroize::Zeroizing::new([0u8; 32]),
FORMAT_VERSION,
false,
);
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut names_unshielded = Vec::new();
while let Some(entry) = stream_unshielded.next().await {
let entry = entry.unwrap();
names_unshielded.push(String::from_utf8_lossy(&entry.name()).to_string());
}
assert!(names_unshielded.contains(&"Thumbs.db".to_string()));
}
#[tokio::test]
async fn test_vfs_activity_tracking() {
let (fs, _dir) = create_test_fs(true);
let act_arc = fs.last_activity();
let initial_time = act_arc.load(Ordering::Relaxed);
assert!(initial_time > 0);
// Manuell zurückdatieren
act_arc.store(1000, Ordering::Relaxed);
assert_eq!(act_arc.load(Ordering::Relaxed), 1000);
// Nach einem VFS-Zugriff muss die Zeit aktualisiert sein
let path = DavPath::new("/test_activity.txt").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let _ = fs.open(&path, opts).await;
let new_time = act_arc.load(Ordering::Relaxed);
assert!(new_time > 1000);
}
}
+441
View File
@@ -0,0 +1,441 @@
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_next_available_drive() {
let drive = find_next_available_drive().expect("Find next drive");
assert!(drive.is_ascii_alphabetic());
assert!(drive >= 'D' && drive <= 'Z');
}
#[test]
fn test_session_lock_monitor_lifecycle() {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let monitor = start_session_lock_monitor(tx);
assert!(monitor.is_ok());
// Dropping monitor closes message loop and joins thread cleanly
drop(monitor);
}
}
+640 -3
View File
@@ -9,10 +9,12 @@ use dav_server::{
use futures_util::StreamExt; use futures_util::StreamExt;
use sanctum::{ use sanctum::{
crypto::{ crypto::{
derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams, CHUNK_SIZE, dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
FORMAT_VERSION, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
}, },
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
storage::Database, storage::Database,
verify::verify_container,
vfs::SanctumFs, vfs::SanctumFs,
}; };
@@ -62,7 +64,7 @@ async fn test_sanctum_full_container_lifecycle() {
assert_eq!(*dek, *unwrapped_dek); assert_eq!(*dek, *unwrapped_dek);
// 3. VFS Filesystem-Operationen (WebDAV Trait) // 3. VFS Filesystem-Operationen (WebDAV Trait)
let fs = SanctumFs::new(db.clone(), unwrapped_dek); let fs = SanctumFs::new(db.clone(), unwrapped_dek, meta.version);
// Ordner erstellen // Ordner erstellen
let docs_path = DavPath::new("/documents").unwrap(); let docs_path = DavPath::new("/documents").unwrap();
@@ -158,3 +160,638 @@ async fn test_sanctum_full_container_lifecycle() {
assert!(container_path.exists()); assert!(container_path.exists());
let _ = std::fs::remove_file(&container_path); let _ = std::fs::remove_file(&container_path);
} }
#[tokio::test]
async fn test_sanctum_password_change() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_passwd_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password_v1 = "InitialSecret123!";
let password_v2 = "NewSecret456!";
// 1. Initialisierung mit Passwort v1
let salt_v1 = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
let original_dek = generate_dek();
let (wrapped_dek_v1, nonce_v1, tag_v1) =
wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1");
let db = Database::open(&container_path).expect("Open database");
db.init_schema(&salt_v1, &kdf_params, &wrapped_dek_v1, &nonce_v1, &tag_v1)
.expect("Init schema");
db.checkpoint().expect("Checkpoint");
// Datei im Container mit DEK anlegen
let fs_v1 = SanctumFs::new(db.clone(), original_dek.clone(), FORMAT_VERSION);
let file_path = DavPath::new("/secret.txt").unwrap();
let file_data = b"Verschluesselte Geheimdaten vor der Passwortaenderung!";
let mut opts_write = OpenOptions::default();
opts_write.write = true;
opts_write.create_new = true;
let mut write_file = fs_v1
.open(&file_path, opts_write)
.await
.expect("Open write");
write_file
.write_bytes(Bytes::from_static(file_data))
.await
.expect("Write bytes");
write_file.flush().await.expect("Flush");
drop(write_file);
drop(fs_v1);
db.checkpoint().expect("Checkpoint");
// 2. Passwortwechsel durchführen
let meta_before = db.read_meta().expect("Read meta");
let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params).expect("Derive old KEK");
let recovered_dek = unwrap_dek(&old_kek, &meta_before.wrapped_dek, &meta_before.header_nonce, &meta_before.header_tag)
.expect("Unwrap with old password");
let salt_v2 = generate_salt();
let kek_v2 = derive_kek(password_v2, &salt_v2, &kdf_params).expect("Derive new KEK");
let (wrapped_dek_v2, nonce_v2, tag_v2) = wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK");
db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2).expect("Update meta keys");
db.checkpoint().expect("Checkpoint");
// 3. Verifikation: Altes Passwort darf NICHT mehr funktionieren
let meta_after = db.read_meta().expect("Read meta after");
let old_kek_again = derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap();
assert!(
unwrap_dek(&old_kek_again, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag).is_err(),
"Altes Passwort darf nach Passwortaenderung nicht mehr funktionieren!"
);
// 4. Verifikation: Neues Passwort funktioniert und entschlüsselt alte Daten intakt
let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params).expect("Derive new KEK");
let active_dek = unwrap_dek(&new_kek, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag)
.expect("Unwrap with new password");
let fs_v2 = SanctumFs::new(db.clone(), active_dek, meta_after.version);
let mut opts_read = OpenOptions::default();
opts_read.read = true;
let mut read_file = fs_v2.open(&file_path, opts_read).await.expect("Open read with new password");
let read_bytes = read_file.read_bytes(file_data.len()).await.expect("Read bytes");
assert_eq!(&read_bytes[..], file_data, "Daten muessen nach Passwortaenderung unveraendert lesbar sein!");
drop(read_file);
drop(fs_v2);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_lz4_compression_efficiency() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_compress_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "CompressTestPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
// 2 MB hochkompressible Textdaten erzeugen (z. B. wiederholende Zeilen)
let pattern = b"Sanctum high performance encrypted container storage with transparent LZ4 compression.\n";
let target_size = 2 * 1024 * 1024;
let mut repetitive_data = Vec::with_capacity(target_size);
while repetitive_data.len() < target_size {
repetitive_data.extend_from_slice(pattern);
}
repetitive_data.truncate(target_size);
// Datei über VFS schreiben
let test_file_path = DavPath::new("/compressed_test.log").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut file = fs.open(&test_file_path, opts).await.expect("open write");
file.write_bytes(Bytes::copy_from_slice(&repetitive_data)).await.expect("write");
file.flush().await.expect("flush");
drop(file);
db.checkpoint().expect("checkpoint");
// Chunk-Knoten ermitteln und Ciphertext-Größe in der Datenbank prüfen
let node = db.resolve_path("/compressed_test.log").expect("resolve").expect("found");
assert_eq!(node.size, target_size as u64);
let chunk0 = db.read_chunk(node.id, 0).expect("read chunk").expect("chunk 0 exists");
// 1 MB Rohdaten komprimiert mit LZ4 sollte typischerweise < 100 KB sein
assert!(
chunk0.ciphertext.len() < 100_000,
"Ciphertext should be compressed from 1MB to < 100KB, was {}",
chunk0.ciphertext.len()
);
// Datei zurücklesen und mit Original vergleichen
let mut read_opts = OpenOptions::default();
read_opts.read = true;
let mut read_file = fs.open(&test_file_path, read_opts).await.expect("open read");
let read_back = read_file.read_bytes(target_size).await.expect("read back");
assert_eq!(read_back.len(), target_size);
assert_eq!(&read_back[..], &repetitive_data[..]);
drop(read_file);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_v1_backward_compatibility() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_v1_compat_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "V1LegacyPassword!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
// Formatversion auf V1 setzen
db.set_meta_version(FORMAT_VERSION_V1).expect("set v1");
let meta = db.read_meta().expect("read meta");
assert_eq!(meta.version, FORMAT_VERSION_V1);
// Datei-Knoten direkt in DB erstellen
let node = db.create_node(1, "legacy_v1.txt", false).expect("create node");
// V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben
let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload.";
let (ct, nonce, tag) = encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1");
db.write_chunk(node.id, 0, &nonce, &tag, &ct).expect("write chunk");
db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678).expect("update size");
db.checkpoint().expect("checkpoint");
// Öffnen über SanctumFs konfiguriert für V1
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION_V1);
let path = DavPath::new("/legacy_v1.txt").unwrap();
let mut opts = OpenOptions::default();
opts.read = true;
let mut file = fs.open(&path, opts).await.expect("open v1 file");
let read_data = file.read_bytes(v1_plaintext.len()).await.expect("read v1");
assert_eq!(&read_data[..], v1_plaintext);
drop(file);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_disaster_recovery_workflow() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let initial_password = "PrimaryPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
// 1. Container erstellen und Datei schreiben
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/confidential.doc").unwrap();
let secret_payload = b"Top secret corporate documents protected by Sanctum.";
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f = fs.open(&test_file, opts_w).await.expect("create file");
f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write");
f.flush().await.expect("flush");
drop(f);
drop(fs);
db.checkpoint().expect("checkpoint");
// 2. Header-Backup exportieren & 24-Wort Notfallschlüssel sichern
export_header_backup(&container_path, &backup_path).expect("Export header backup");
assert!(backup_path.exists());
let recovery_phrase = dek_to_mnemonic(&dek).expect("Generate 24-word recovery phrase");
assert_eq!(recovery_phrase.split_whitespace().count(), 24);
// 3. Integritätsprüfung vor Katastrophe
let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial");
assert!(initial_report.is_healthy());
assert_eq!(initial_report.total_files, 1);
assert_eq!(initial_report.corrupted_chunks, 0);
// 4. KATASTROPHE 1: Header in SQLite löschen
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Container darf sich ohne Header nicht mehr öffnen lassen
let broken_db = Database::open(&container_path).unwrap();
assert!(broken_db.read_meta().is_err());
drop(broken_db);
// 5. WIEDERHERSTELLUNG 1: Aus externem .sanctum.hdr Backup restoren
restore_header_backup(&container_path, &backup_path).expect("Restore from backup file");
let restored_db = Database::open(&container_path).unwrap();
let restored_meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap();
let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap();
let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version);
let mut opts_r = OpenOptions::default();
opts_r.read = true;
let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored");
let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore");
drop(rf1);
drop(fs1);
drop(restored_db);
// 6. KATASTROPHE 2: Header erneut zerstört UND ursprüngliches Passwort vergessen!
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// 7. WIEDERHERSTELLUNG 2: Via 24-Wort Notfallschlüssel mit BRANDNEUEM Passwort
let brand_new_password = "BrandNewRescuedVaultPassphrase2026!";
restore_header_from_recovery_key(&container_path, &recovery_phrase, brand_new_password)
.expect("Restore from 24-word recovery key");
let rescued_db = Database::open(&container_path).unwrap();
let rescued_meta = rescued_db.read_meta().expect("Read rescued meta");
let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap();
let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap();
let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version);
let mut opts_r2 = OpenOptions::default();
opts_r2.read = true;
let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password");
let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue");
drop(rf2);
drop(fs2);
drop(rescued_db);
// 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert
let conn = rusqlite::Connection::open(&container_path).unwrap();
let mut current_ct: Vec<u8> = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap();
current_ct[0] ^= 0x01; // Bit-Flip
conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap();
drop(conn);
let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot");
assert!(!bitrot_report.is_healthy(), "Container must flag bitrot");
assert_eq!(bitrot_report.corrupted_chunks, 1);
// Aufräumen
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_anti_leak_and_inactivity_shield() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_leak_shield_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "ShieldedContainerPass2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).expect("derive kek");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap dek");
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.expect("init schema");
db.checkpoint().expect("checkpoint");
// 1. Erstelle Filesystem mit aktivem Anti-Leak Shield (Standard)
let fs = SanctumFs::with_options(db.clone(), dek.clone(), FORMAT_VERSION, true);
// 2. Windows Explorer Leck-Dateien müssen abgewehrt werden
let leak_files = [
"/Thumbs.db",
"/thumbs.db",
"/desktop.ini",
"/Desktop.ini",
"/Folder.jpg",
"/AlbumArt_{A1B2C3D4-E5F6}_Large.jpg",
"/albumartsmall.jpg",
"/ehthumbs.db",
"/autorun.inf",
];
for leak in leak_files {
let path = DavPath::new(leak).unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let res = fs.open(&path, opts).await;
assert!(
matches!(res, Err(dav_server::fs::FsError::Forbidden)),
"Leak-Datei '{}' muss mit FsError::Forbidden blockiert werden",
leak
);
}
// Ordnererstellung mit Leck-Namen muss ebenfalls blockiert werden
let leak_dir = DavPath::new("/Thumbs.db").unwrap();
let dir_res = fs.create_dir(&leak_dir).await;
assert!(matches!(dir_res, Err(dav_server::fs::FsError::Forbidden)));
// 3. Legitime Dateien müssen reibungslos funktionieren
let legit_path = DavPath::new("/top_secret.docx").unwrap();
let mut opts_legit = OpenOptions::default();
opts_legit.write = true;
opts_legit.create_new = true;
let mut file = fs.open(&legit_path, opts_legit).await.expect("create legit file");
file.write_bytes(Bytes::from_static(b"Sanctum OpSec Shield Test"))
.await
.expect("write data");
file.flush().await.expect("flush data");
drop(file);
// 4. Inaktivitäts-Tracking überprüfen
let activity_tracker = fs.last_activity();
let recorded_now = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
assert!(recorded_now > 0);
// Zurückdatieren um 300 Sekunden
activity_tracker.store(recorded_now - 300, std::sync::atomic::Ordering::Relaxed);
let past = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
// Datei lesen
let mut opts_read = OpenOptions::default();
opts_read.read = true;
let mut rf = fs.open(&legit_path, opts_read).await.expect("open legit");
let content = rf.read_bytes(25).await.expect("read bytes");
assert_eq!(&content[..], b"Sanctum OpSec Shield Test");
drop(rf);
let updated_time = activity_tracker.load(std::sync::atomic::Ordering::Relaxed);
assert!(
updated_time > past,
"VFS-Aktivität muss den Inaktivitäts-Timer aktualisieren"
);
// 5. Deaktivierter Shield (--no-anti-leak) erlaubt Explorer-Metadaten
let unshielded_fs = SanctumFs::with_options(db.clone(), dek.clone(), FORMAT_VERSION, false);
let mut unshielded_opts = OpenOptions::default();
unshielded_opts.write = true;
unshielded_opts.create_new = true;
let desktop_ini_path = DavPath::new("/desktop.ini").unwrap();
let desktop_res = unshielded_fs.open(&desktop_ini_path, unshielded_opts).await;
assert!(
desktop_res.is_ok(),
"Ohne Shield muss Erstellung von desktop.ini gestattet sein"
);
// Aufräumen
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_hidden_vault_and_storage_compaction_integration() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_hidden_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password_decoy = "DecoyOuterPassword2026!";
let password_hidden = "TopSecretHiddenPassword2026!";
// 1. Dual-Vault Initialisierung (Slot 0 = Decoy, Slot 1 = Hidden Vault)
let salt0 = generate_salt();
let salt1 = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek0 = derive_kek(password_decoy, &salt0, &kdf_params).expect("KEK 0");
let kek1 = derive_kek(password_hidden, &salt1, &kdf_params).expect("KEK 1");
let dek0 = generate_dek();
let dek1 = generate_dek();
let (wrapped_dek0, nonce0, tag0) = wrap_dek(&kek0, &dek0).expect("Wrap DEK 0");
let (wrapped_dek1, nonce1, tag1) = wrap_dek(&kek1, &dek1).expect("Wrap DEK 1");
let db = Database::open(&container_path).expect("Open container");
db.init_schema_with_hidden(
&salt0,
&kdf_params,
&wrapped_dek0,
&nonce0,
&tag0,
Some((&salt1, &kdf_params, &wrapped_dek1, &nonce1, &tag1)),
)
.expect("Init schema with hidden");
db.checkpoint().expect("Checkpoint init");
// 2. Multi-Slot Authentifizierungsprüfung
let meta = db.read_meta().expect("Read meta");
assert_eq!(meta.slots.len(), 2, "Es müssen 2 Slots initialisiert sein");
// Decoy Passwort entsperrt Slot 0
let mut auth_decoy = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(password_decoy, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
auth_decoy = Some((dek, slot.slot_id));
break;
}
}
}
let (unwrapped_dek0, slot_id0) = auth_decoy.expect("Decoy password must unwrap");
assert_eq!(slot_id0, 0);
assert_eq!(*unwrapped_dek0, *dek0);
// Hidden Passwort entsperrt Slot 1
let mut auth_hidden = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(password_hidden, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
auth_hidden = Some((dek, slot.slot_id));
break;
}
}
}
let (unwrapped_dek1, slot_id1) = auth_hidden.expect("Hidden password must unwrap");
assert_eq!(slot_id1, 1);
assert_eq!(*unwrapped_dek1, *dek1);
// 3. VFS Operationen im Decoy-Vault (Slot 0)
let fs_decoy = SanctumFs::with_vault(db.clone(), unwrapped_dek0, meta.version, true, 0);
let decoy_file_path = DavPath::new("/harmless_recipe.txt").unwrap();
let mut opts_write = OpenOptions::default();
opts_write.write = true;
opts_write.create_new = true;
let mut df = fs_decoy.open(&decoy_file_path, opts_write).await.expect("Open decoy file");
df.write_bytes(Bytes::from_static(b"Apples, Flour, Sugar, Butter"))
.await
.expect("Write decoy");
df.flush().await.expect("Flush decoy");
drop(df);
// 4. VFS Operationen im Hidden-Vault (Slot 1)
let fs_hidden = SanctumFs::with_vault(db.clone(), unwrapped_dek1, meta.version, true, 1);
let hidden_file_path = DavPath::new("/classified_report.pdf").unwrap();
let mut opts_write2 = OpenOptions::default();
opts_write2.write = true;
opts_write2.create_new = true;
let mut hf = fs_hidden.open(&hidden_file_path, opts_write2).await.expect("Open hidden file");
hf.write_bytes(Bytes::from_static(b"TOP SECRET INTELLIGENCE DATA"))
.await
.expect("Write hidden");
hf.flush().await.expect("Flush hidden");
drop(hf);
// 5. Strikte Isolierung verifizieren: Decoy-Vault sieht NICHTS vom Hidden-Vault
let mut stream_decoy = fs_decoy
.read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None)
.await
.expect("read_dir decoy");
let mut decoy_entries = Vec::new();
while let Some(Ok(entry)) = stream_decoy.next().await {
decoy_entries.push(String::from_utf8_lossy(&entry.name()).to_string());
}
assert_eq!(decoy_entries, vec!["harmless_recipe.txt"]);
assert!(fs_decoy.metadata(&hidden_file_path).await.is_err(), "Decoy darf classified_report nicht sehen");
// Hidden-Vault sieht ebenfalls nur seine eigenen Dateien
let mut stream_hidden = fs_hidden
.read_dir(&DavPath::new("/").unwrap(), ReadDirMeta::None)
.await
.expect("read_dir hidden");
let mut hidden_entries = Vec::new();
while let Some(Ok(entry)) = stream_hidden.next().await {
hidden_entries.push(String::from_utf8_lossy(&entry.name()).to_string());
}
assert_eq!(hidden_entries, vec!["classified_report.pdf"]);
assert!(fs_hidden.metadata(&decoy_file_path).await.is_err(), "Hidden darf harmless_recipe nicht auflösen");
// 6. Forensische Dateiprüfung (Anti-Forensics / Plausible Deniability)
db.checkpoint().expect("Checkpoint before raw inspection");
let raw_bytes = std::fs::read(&container_path).expect("Read container raw bytes");
// Der Dateiname "classified_report.pdf" darf NIRGENDS im Rohformat im Container stehen
let needle_filename = b"classified_report";
assert!(
!raw_bytes.windows(needle_filename.len()).any(|w| w == needle_filename),
"Forensischer Leak: Dateiname des Hidden Vaults taucht als Klartext in der Datei auf!"
);
let needle_payload = b"TOP SECRET INTELLIGENCE DATA";
assert!(
!raw_bytes.windows(needle_payload.len()).any(|w| w == needle_payload),
"Forensischer Leak: Nutzlast des Hidden Vaults taucht als Klartext auf!"
);
// 7. Storage Compaction (Incremental Vacuum & Chunk Shredding)
// Große temporäre Datei im Hidden Vault anlegen
let temp_large_path = DavPath::new("/large_dump.dat").unwrap();
let mut opts_dump = OpenOptions::default();
opts_dump.write = true;
opts_dump.create_new = true;
let mut dump_file = fs_hidden.open(&temp_large_path, opts_dump).await.expect("Open dump");
let mut large_buffer = vec![0u8; 512 * 1024]; // 512 KB echte Zufallsdaten (Shannon-Entropie 8.0)
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut large_buffer);
dump_file.write_bytes(Bytes::copy_from_slice(&large_buffer)).await.expect("Write dump");
dump_file.flush().await.expect("Flush dump");
drop(dump_file);
db.checkpoint().expect("Checkpoint after write");
// Datei löschen (löst automatisches Chunk-Shredding aus)
fs_hidden.remove_file(&temp_large_path).await.expect("Remove dump");
db.checkpoint().expect("Checkpoint after remove");
let free_pages = db.freelist_count().expect("freelist count");
assert!(free_pages > 0, "Nach dem Löschen müssen freie Seiten in der Freelist existieren");
// Incremental Vacuum ausführen
let reclaimed = db.incremental_vacuum(None).expect("incremental vacuum");
assert!(reclaimed > 0, "Seiten müssen an das Dateisystem zurückgegeben werden");
assert_eq!(db.freelist_count().unwrap(), 0, "Freelist muss jetzt 0 sein");
// Verifiziere, dass verbleibende Dateien in beiden Vaults intakt lesbar sind
let mut r_decoy = fs_decoy.open(&decoy_file_path, OpenOptions::default()).await.expect("open decoy");
let d_data = r_decoy.read_bytes(30).await.expect("read decoy");
assert_eq!(&d_data[..], b"Apples, Flour, Sugar, Butter");
let mut r_hidden = fs_hidden.open(&hidden_file_path, OpenOptions::default()).await.expect("open hidden");
let h_data = r_hidden.read_bytes(30).await.expect("read hidden");
assert_eq!(&h_data[..], b"TOP SECRET INTELLIGENCE DATA");
// Aufräumen
let _ = std::fs::remove_file(&container_path);
}