diff --git a/data/auth.sqlite b/data/auth.sqlite index a702ab8..dc05f5d 100644 Binary files a/data/auth.sqlite and b/data/auth.sqlite differ diff --git a/markbase-core/src/vfs/encrypted_fs.rs b/markbase-core/src/vfs/encrypted_fs.rs new file mode 100644 index 0000000..7c7d85c --- /dev/null +++ b/markbase-core/src/vfs/encrypted_fs.rs @@ -0,0 +1,344 @@ +//! Encrypted VFS Backend - Transparent at-rest encryption using AES-256-GCM +//! +//! This module provides transparent file encryption at the VFS layer. +//! Files are encrypted before being written to disk and decrypted on read. +//! +//! Format: +//! - Header (32 bytes): magic(4) + version(4) + nonce(12) + original_size(8) + reserved(4) +//! - Body: AES-256-GCM encrypted data +//! - Tag (16 bytes): GCM authentication tag + +use std::path::PathBuf; +use std::io::{Seek, SeekFrom}; + +use aes_gcm::{ + Aes256Gcm, Nonce, aead::{Aead, KeyInit}, +}; +use sha2::{Sha256, Digest}; + +use super::{VfsBackend, VfsFile, VfsStat, VfsError, VfsDirEntry}; +use super::open_flags::OpenFlags; +use super::local_fs::LocalFs; + +const ENCRYPTED_MAGIC: &[u8] = b"MBE1"; // MarkBase Encrypted v1 +const ENCRYPTED_VERSION: u32 = 1; +const HEADER_SIZE: usize = 32; +const TAG_SIZE: usize = 16; +const NONCE_SIZE: usize = 12; +const KEY_SIZE: usize = 32; + +#[derive(Debug, Clone)] +pub struct EncryptedVfsConfig { + pub master_key: Vec, // 32 bytes for AES-256 + pub encrypt_filenames: bool, // Future feature +} + +impl EncryptedVfsConfig { + pub fn new(master_key: [u8; 32]) -> Self { + Self { + master_key: master_key.to_vec(), + encrypt_filenames: false, + } + } + + pub fn from_password(password: &str) -> Self { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + let key = hasher.finalize(); + Self { + master_key: key.to_vec(), + encrypt_filenames: false, + } + } +} + +pub struct EncryptedVfs { + inner: Box, + config: EncryptedVfsConfig, +} + +impl EncryptedVfs { + pub fn new(inner: Box, config: EncryptedVfsConfig) -> Self { + Self { inner, config } + } + + pub fn wrap_local_fs(root: PathBuf, config: EncryptedVfsConfig) -> Self { + Self::new(Box::new(LocalFs::new()), config) + } + + fn derive_key(&self, path: &PathBuf) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(&self.config.master_key); + hasher.update(path.to_string_lossy().as_bytes()); + let derived = hasher.finalize(); + derived[..KEY_SIZE].to_vec() + } + + pub fn is_encrypted_file(data: &[u8]) -> bool { + data.len() >= HEADER_SIZE + TAG_SIZE && &data[..4] == ENCRYPTED_MAGIC + } + + fn encrypt_data(&self, path: &PathBuf, data: &[u8]) -> Result, VfsError> { + let key_bytes = self.derive_key(path); + let cipher = Aes256Gcm::new_from_slice(&key_bytes) + .map_err(|e| VfsError::Io(format!("cipher init failed: {}", e)))?; + + let nonce_bytes: [u8; NONCE_SIZE] = rand_key(12).try_into().map_err(|_| VfsError::Io("nonce generation failed".to_string()))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher.encrypt(nonce, data) + .map_err(|e| VfsError::Io(format!("encryption failed: {}", e)))?; + + let mut result = Vec::with_capacity(HEADER_SIZE + ciphertext.len() + TAG_SIZE); + + result.extend_from_slice(ENCRYPTED_MAGIC); + result.extend_from_slice(&ENCRYPTED_VERSION.to_le_bytes()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&(data.len() as u64).to_le_bytes()); + result.extend_from_slice(&[0u8; 4]); + result.extend_from_slice(&ciphertext); + + Ok(result) + } + + fn decrypt_data(&self, path: &PathBuf, data: &[u8]) -> Result, VfsError> { + if !Self::is_encrypted_file(data) { + return Err(VfsError::Io("not an encrypted file".to_string())); + } + + let key_bytes = self.derive_key(path); + let cipher = Aes256Gcm::new_from_slice(&key_bytes) + .map_err(|e| VfsError::Io(format!("cipher init failed: {}", e)))?; + + let nonce_bytes: [u8; NONCE_SIZE] = data[8..20].try_into().map_err(|_| VfsError::Io("invalid nonce".to_string()))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let original_size = u64::from_le_bytes(data[20..28].try_into().map_err(|_| VfsError::Io("invalid size".to_string()))?) as usize; + + let ciphertext = &data[HEADER_SIZE..]; + + let plaintext = cipher.decrypt(nonce, ciphertext) + .map_err(|e| VfsError::Io(format!("decryption failed: {}", e)))?; + + if plaintext.len() != original_size { + return Err(VfsError::Io(format!("size mismatch: expected {}, got {}", original_size, plaintext.len()))); + } + + Ok(plaintext) + } +} + +fn rand_key(len: usize) -> Vec { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let mut hasher = Sha256::new(); + hasher.update(&now.to_le_bytes()); + hasher.update(&[0u8; 32]); + let hash = hasher.finalize(); + hash[..len].to_vec() +} + +pub struct EncryptedFile { + inner: Box, + path: PathBuf, + config: EncryptedVfsConfig, + decrypted_data: Option>, + modified: bool, + position: u64, +} + +impl EncryptedFile { + fn decrypt_on_open(&mut self) -> Result<(), VfsError> { + let encrypted = self.inner.read_all()?; + + if EncryptedVfs::is_encrypted_file(&encrypted) { + let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), self.config.clone()); + self.decrypted_data = Some(vfs.decrypt_data(&self.path, &encrypted)?); + } else { + self.decrypted_data = Some(encrypted); + } + + Ok(()) + } + + fn encrypt_on_close(&mut self) -> Result<(), VfsError> { + if !self.modified { + return Ok(()); + } + + let data = self.decrypted_data.as_ref().ok_or_else(|| VfsError::Io("no data to encrypt".to_string()))?; + + let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), self.config.clone()); + let encrypted = vfs.encrypt_data(&self.path, data)?; + + self.inner.seek(SeekFrom::Start(0))?; + self.inner.write_all(&encrypted)?; + + Ok(()) + } +} + +impl VfsFile for EncryptedFile { + fn read(&mut self, buf: &mut [u8]) -> Result { + if self.decrypted_data.is_none() { + self.decrypt_on_open()?; + } + + let data = self.decrypted_data.as_ref().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?; + + let start = self.position as usize; + let end = std::cmp::min(start + buf.len(), data.len()); + + if start >= data.len() { + return Ok(0); + } + + buf[..(end - start)].copy_from_slice(&data[start..end]); + self.position += (end - start) as u64; + + Ok(end - start) + } + + fn write(&mut self, buf: &[u8]) -> Result { + if self.decrypted_data.is_none() { + self.decrypted_data = Some(Vec::new()); + } + + let data = self.decrypted_data.as_mut().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?; + + let start = self.position as usize; + if start + buf.len() > data.len() { + data.resize(start + buf.len(), 0); + } + + data[start..start + buf.len()].copy_from_slice(buf); + self.position += buf.len() as u64; + self.modified = true; + + Ok(buf.len()) + } + + fn seek(&mut self, pos: SeekFrom) -> Result { + match pos { + SeekFrom::Start(offset) => { + self.position = offset; + } + SeekFrom::Current(offset) => { + self.position = (self.position as i64 + offset) as u64; + } + SeekFrom::End(offset) => { + let len = self.decrypted_data.as_ref().map(|d| d.len() as i64).unwrap_or(0); + self.position = (len + offset) as u64; + } + } + Ok(self.position) + } + + fn flush(&mut self) -> Result<(), VfsError> { + self.encrypt_on_close()?; + self.inner.flush()?; + Ok(()) + } + + fn stat(&mut self) -> Result { + let stat = self.inner.stat()?; + Ok(VfsStat { + size: self.decrypted_data.as_ref().map(|d| d.len() as u64).unwrap_or(stat.size), + mode: stat.mode, + uid: stat.uid, + gid: stat.gid, + atime: stat.atime, + mtime: stat.mtime, + is_dir: false, + is_symlink: false, + }) + } + + fn set_len(&mut self, size: u64) -> Result<(), VfsError> { + if self.decrypted_data.is_none() { + self.decrypted_data = Some(Vec::new()); + } + + let data = self.decrypted_data.as_mut().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?; + data.resize(size as usize, 0); + self.modified = true; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let config = EncryptedVfsConfig::from_password("test_password"); + let path = PathBuf::from("/test/file.txt"); + + let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config.clone()); + + let original = b"Hello, World! This is a test message."; + let encrypted = vfs.encrypt_data(&path, original).unwrap(); + + assert!(encrypted.len() > original.len()); + assert!(EncryptedVfs::is_encrypted_file(&encrypted)); + + let decrypted = vfs.decrypt_data(&path, &encrypted).unwrap(); + assert_eq!(decrypted, original); + } + + #[test] + fn test_different_keys_produce_different_ciphertext() { + let config1 = EncryptedVfsConfig::from_password("password1"); + let config2 = EncryptedVfsConfig::from_password("password2"); + let path = PathBuf::from("/test/file.txt"); + + let vfs1 = EncryptedVfs::new(Box::new(LocalFs::new()), config1); + let vfs2 = EncryptedVfs::new(Box::new(LocalFs::new()), config2); + + let original = b"Same content"; + + let enc1 = vfs1.encrypt_data(&path, original).unwrap(); + let enc2 = vfs2.encrypt_data(&path, original).unwrap(); + + assert_ne!(enc1, enc2); + } + + #[test] + fn test_key_derivation() { + let config = EncryptedVfsConfig::from_password("test_password"); + let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config); + + let key1 = vfs.derive_key(&PathBuf::from("/file1.txt")); + let key2 = vfs.derive_key(&PathBuf::from("/file2.txt")); + + assert_ne!(key1, key2); + } + + #[test] + fn test_header_format() { + let config = EncryptedVfsConfig::from_password("test"); + let path = PathBuf::from("/test.txt"); + let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config); + + let data = b"test"; + let encrypted = vfs.encrypt_data(&path, data).unwrap(); + + assert_eq!(&encrypted[..4], ENCRYPTED_MAGIC); + assert_eq!(u32::from_le_bytes(encrypted[4..8].try_into().unwrap()), ENCRYPTED_VERSION); + assert_eq!(encrypted.len(), HEADER_SIZE + data.len() + TAG_SIZE); + } + + #[test] + fn test_config_from_password() { + let config = EncryptedVfsConfig::from_password("my_secret_password"); + assert_eq!(config.master_key.len(), KEY_SIZE); + + let config2 = EncryptedVfsConfig::from_password("my_secret_password"); + assert_eq!(config.master_key, config2.master_key); + + let config3 = EncryptedVfsConfig::from_password("different"); + assert_ne!(config.master_key, config3.master_key); + } +} \ No newline at end of file diff --git a/markbase-core/src/vfs/mod.rs b/markbase-core/src/vfs/mod.rs index b32efd0..3a44633 100644 --- a/markbase-core/src/vfs/mod.rs +++ b/markbase-core/src/vfs/mod.rs @@ -1,6 +1,7 @@ pub mod cache; pub mod compression; pub mod dedup; +pub mod encrypted_fs; pub mod local_fs; pub mod open_flags; pub mod raid;