feat(opsec): implement inactivity auto-lock, session lock detection, and anti-leak shield
This commit is contained in:
+290
-3
@@ -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<u8>, bool)>,
|
||||
format_version: u32,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl Debug for SanctumFile {
|
||||
@@ -106,6 +133,7 @@ impl SanctumFile {
|
||||
db: Database,
|
||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
||||
format_version: u32,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
) -> 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<Zeroizing<[u8; 32]>>,
|
||||
format_version: u32,
|
||||
anti_leak: bool,
|
||||
last_activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
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<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 {
|
||||
String::from_utf8_lossy(path.as_bytes()).to_string()
|
||||
}
|
||||
@@ -378,6 +452,25 @@ impl DavFileSystem for SanctumFs {
|
||||
) -> FsFuture<'a, Box<dyn DavFile>> {
|
||||
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<dyn DavFile>)
|
||||
})
|
||||
}
|
||||
@@ -451,6 +549,7 @@ impl DavFileSystem for SanctumFs {
|
||||
_meta: ReadDirMeta,
|
||||
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
|
||||
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<Result<Box<dyn DavDirEntry>, FsError>> = children
|
||||
.into_iter()
|
||||
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
|
||||
.map(|child| {
|
||||
Ok(Box::new(SanctumDirEntry {
|
||||
name: child.name,
|
||||
@@ -489,6 +589,9 @@ impl DavFileSystem for SanctumFs {
|
||||
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
||||
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::<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user