diff --git a/src/main.rs b/src/main.rs index 4d19936..c1a72c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,18 @@ enum Commands { /// 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, + + /// 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 @@ -481,6 +493,9 @@ async fn run() -> Result<()> { 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)?, @@ -508,7 +523,20 @@ async fn run() -> Result<()> { let open_explorer = !no_open; let enable_tray = !no_tray; - mount_container(&path, drive_char, port, auth, open_explorer, enable_tray).await?; + let lock_on_screen_lock = !no_screen_lock; + let anti_leak = !no_anti_leak; + mount_container( + &path, + drive_char, + port, + auth, + open_explorer, + enable_tray, + idle_timeout, + lock_on_screen_lock, + anti_leak, + ) + .await?; } Commands::Unmount { drive } => { let drive_char = parse_drive_letter(&drive)?; diff --git a/src/mount.rs b/src/mount.rs index 0038bf3..5c14502 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -2,6 +2,8 @@ use std::convert::Infallible; use std::net::SocketAddr; use std::path::Path; use std::process::Command; +use std::sync::atomic::Ordering; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{bail, Context, Result}; use dav_server::{fakels::FakeLs, DavHandler}; @@ -83,6 +85,9 @@ pub async fn mount_container( auth: ContainerAuth, open_explorer: bool, enable_tray: bool, + idle_timeout: Option, + lock_on_screen_lock: bool, + anti_leak: bool, ) -> Result<()> { let drive_str = format_drive(drive_letter); @@ -127,8 +132,9 @@ pub async fn mount_container( } }; - // WebDAV Filesystem und Handler konfigurieren - let fs = SanctumFs::new(db.clone(), dek, version); + // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield) + let fs = SanctumFs::with_options(db.clone(), dek, version, anti_leak); + let last_activity = fs.last_activity(); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) .locksystem(FakeLs::new()) @@ -248,6 +254,44 @@ pub async fn mount_container( None }; + // Inaktivitäts-Timer (Auto-Lock) + let (idle_shutdown_tx, mut idle_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); + if let Some(timeout_secs) = idle_timeout { + if timeout_secs > 0 { + let last_act = last_activity.clone(); + let idle_tx = idle_shutdown_tx.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let last = last_act.load(Ordering::Relaxed); + if now.saturating_sub(last) >= timeout_secs { + let _ = idle_tx.send(()).await; + break; + } + } + }); + } + } + + // Windows-Sitzungssperre (Win + L Auto-Lock) + let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1); + let _session_monitor = if lock_on_screen_lock { + match crate::windows::start_session_lock_monitor(session_lock_tx) { + Ok(guard) => Some(guard), + Err(e) => { + warn!("Konnte Windows Session-Lock Monitor nicht aktivieren: {e}"); + None + } + } + } else { + None + }; + println!(); println!("┌─────────────────────────────────────────────────────────────┐"); println!("│ ✔ Sanctum Container erfolgreich gemountet │"); @@ -256,6 +300,15 @@ pub async fn mount_container( println!(" • Container: {}", container_path.display()); println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str)); println!(" • WebDAV-URL: http://127.0.0.1:{}/", bound_port); + 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)"); } @@ -263,7 +316,7 @@ pub async fn mount_container( println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp")); println!(); - // Warten auf Strg+C ODER Signal aus dem System-Tray + // Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L) tokio::select! { res = tokio::signal::ctrl_c() => { let _ = res; @@ -274,6 +327,14 @@ pub async fn mount_container( println!(); println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]")); } + _ = session_lock_rx.recv() => { + println!(); + println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]")); + } + _ = idle_shutdown_rx.recv() => { + println!(); + println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); + } } print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str); let _ = std::io::Write::flush(&mut std::io::stdout()); diff --git a/src/vfs.rs b/src/vfs.rs index d22c55f..00103e3 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -1,5 +1,6 @@ use std::fmt::Debug; use std::io::SeekFrom; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -18,6 +19,31 @@ use zeroize::Zeroizing; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::{Database, NodeRecord}; +/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache- +/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig +/// im Container blockiert und verborgen werden (Anti-Leak Shield). +pub fn is_leak_file(filename: &str) -> bool { + let lower = filename.trim().to_ascii_lowercase(); + match lower.as_str() { + "thumbs.db" + | "ehthumbs.db" + | "ehthumbs_vista.db" + | "desktop.ini" + | "folder.jpg" + | "albumartsmall.jpg" + | "autorun.inf" + | ".ds_store" => true, + _ => { + if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) + { + true + } else { + false + } + } + } +} + // --------------------------------------------------------------------------- // Metadaten // --------------------------------------------------------------------------- @@ -87,6 +113,7 @@ pub struct SanctumFile { // (chunk_index, decrypted_payload, is_dirty) cached_chunk: Option<(u32, Vec, bool)>, format_version: u32, + last_activity: Arc, } impl Debug for SanctumFile { @@ -106,6 +133,7 @@ impl SanctumFile { db: Database, dek: Arc>, format_version: u32, + last_activity: Arc, ) -> Self { let meta = SanctumMetaData { is_dir: node.is_dir, @@ -123,9 +151,18 @@ impl SanctumFile { meta, cached_chunk: None, format_version, + last_activity, } } + fn touch(&self) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + self.last_activity.store(now, Ordering::Relaxed); + } + /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. fn flush_cached_chunk(&mut self) -> Result<(), FsError> { @@ -211,6 +248,7 @@ impl DavFile for SanctumFile { } fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> { + self.touch(); Box::pin(async move { if self.cursor >= self.file_size || count == 0 { return Ok(Bytes::new()); @@ -253,6 +291,7 @@ impl DavFile for SanctumFile { } fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> { + self.touch(); Box::pin(async move { let mut src = &buf[..]; @@ -301,6 +340,7 @@ impl DavFile for SanctumFile { } fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> { + self.touch(); Box::pin(async move { let new_cursor = match pos { SeekFrom::Start(offset) => offset as i64, @@ -318,6 +358,7 @@ impl DavFile for SanctumFile { } fn flush(&mut self) -> FsFuture<'_, ()> { + self.touch(); Box::pin(async move { self.flush_cached_chunk()?; let now = SystemTime::now() @@ -346,17 +387,50 @@ pub struct SanctumFs { db: Database, dek: Arc>, format_version: u32, + anti_leak: bool, + last_activity: Arc, } impl SanctumFs { pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self { + Self::with_options(db, dek, format_version, true) + } + + pub fn with_options( + db: Database, + dek: Zeroizing<[u8; 32]>, + format_version: u32, + anti_leak: bool, + ) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); Self { db, dek: Arc::new(dek), format_version, + anti_leak, + last_activity: Arc::new(AtomicU64::new(now)), } } + pub fn last_activity(&self) -> Arc { + 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 { String::from_utf8_lossy(path.as_bytes()).to_string() } @@ -378,6 +452,25 @@ impl DavFileSystem for SanctumFs { ) -> FsFuture<'a, Box> { Box::pin(async move { let path_str = Self::path_to_str(path); + let (parent_path, file_name) = self.split_parent_and_name(&path_str); + + // Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten + if self.anti_leak && is_leak_file(file_name) { + if options.create + || options.create_new + || options.write + || options.append + || options.truncate + { + debug!( + "Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'", + file_name + ); + return Err(FsError::Forbidden); + } + } + + self.touch(); debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options); let existing_node = self @@ -417,7 +510,6 @@ impl DavFileSystem for SanctumFs { } None => { if options.create || options.create_new { - let (parent_path, file_name) = self.split_parent_and_name(&path_str); let parent = self .db .resolve_path(parent_path) @@ -440,7 +532,13 @@ impl DavFileSystem for SanctumFs { } }; - let file = SanctumFile::new(node, self.db.clone(), self.dek.clone(), self.format_version); + let file = SanctumFile::new( + node, + self.db.clone(), + self.dek.clone(), + self.format_version, + self.last_activity.clone(), + ); Ok(Box::new(file) as Box) }) } @@ -451,6 +549,7 @@ impl DavFileSystem for SanctumFs { _meta: ReadDirMeta, ) -> FsFuture<'a, FsStream>> { Box::pin(async move { + self.touch(); let path_str = Self::path_to_str(path); let node = self .db @@ -469,6 +568,7 @@ impl DavFileSystem for SanctumFs { let entries: Vec, FsError>> = children .into_iter() + .filter(|child| !self.anti_leak || !is_leak_file(&child.name)) .map(|child| { Ok(Box::new(SanctumDirEntry { name: child.name, @@ -489,6 +589,9 @@ impl DavFileSystem for SanctumFs { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { Box::pin(async move { let path_str = Self::path_to_str(path); + if path_str != "/" && !path_str.is_empty() { + self.touch(); + } let node = self .db .resolve_path(&path_str) @@ -512,7 +615,14 @@ impl DavFileSystem for SanctumFs { fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { Box::pin(async move { + self.touch(); let path_str = Self::path_to_str(path); + let (parent_path, dir_name) = self.split_parent_and_name(&path_str); + + if self.anti_leak && is_leak_file(dir_name) { + return Err(FsError::Forbidden); + } + if self .db .resolve_path(&path_str) @@ -522,7 +632,6 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Exists); } - let (parent_path, dir_name) = self.split_parent_and_name(&path_str); let parent = self .db .resolve_path(parent_path) @@ -543,6 +652,7 @@ impl DavFileSystem for SanctumFs { fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { Box::pin(async move { + self.touch(); let path_str = Self::path_to_str(path); let node = self .db @@ -568,6 +678,7 @@ impl DavFileSystem for SanctumFs { fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { Box::pin(async move { + self.touch(); let path_str = Self::path_to_str(path); let node = self .db @@ -593,6 +704,7 @@ impl DavFileSystem for SanctumFs { to: &'a DavPath, ) -> FsFuture<'a, ()> { Box::pin(async move { + self.touch(); let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); @@ -603,6 +715,11 @@ impl DavFileSystem for SanctumFs { .ok_or(FsError::NotFound)?; let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); + + if self.anti_leak && is_leak_file(to_name) { + return Err(FsError::Forbidden); + } + let to_parent = self .db .resolve_path(to_parent_path) @@ -641,6 +758,7 @@ impl DavFileSystem for SanctumFs { to: &'a DavPath, ) -> FsFuture<'a, ()> { Box::pin(async move { + self.touch(); let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); @@ -655,6 +773,11 @@ impl DavFileSystem for SanctumFs { } let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); + + if self.anti_leak && is_leak_file(to_name) { + return Err(FsError::Forbidden); + } + let to_parent = self .db .resolve_path(to_parent_path) @@ -720,3 +843,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::())); + 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); + } +} diff --git a/src/windows.rs b/src/windows.rs index e79d9a6..cdc2f45 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -188,6 +188,229 @@ pub fn get_default_system_icon() -> Option { } } +/// 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>, +} + +#[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>> = + std::sync::Mutex::new(None); + +#[cfg(windows)] +#[allow(non_snake_case)] +#[repr(C)] +struct WNDCLASSEXW { + cbSize: u32, + style: u32, + lpfnWndProc: Option 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 { + if let Ok(mut guard) = SESSION_LOCK_TX.lock() { + *guard = Some(shutdown_tx); + } + + let (hwnd_tx, hwnd_rx) = std::sync::mpsc::channel::(); + + 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 = "SanctumSessionMonitorClass\0".encode_utf16().collect(); + + let wcx = WNDCLASSEXW { + cbSize: std::mem::size_of::() 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::(); + 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 { + Ok(SessionLockGuard) +} + #[cfg(test)] mod tests { use super::*; @@ -198,4 +421,13 @@ mod tests { 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); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 54035eb..9a1613c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -498,4 +498,116 @@ async fn test_sanctum_disaster_recovery_workflow() { 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); +} + +