From 7c4476e19c1865c9624dda59c513d0f5ed5fb024 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 24 Jun 2026 00:57:53 +0800 Subject: [PATCH] Implement at-rest encryption: AES-256-GCM VFS layer - Added encrypted_fs.rs module for transparent file encryption - EncryptedVfs wraps any VfsBackend with AES-256-GCM encryption - Per-file key derivation from master key + file path (SHA-256) - File format: MBE1 magic + version + nonce + original_size + ciphertext + tag - EncryptedFile transparently decrypts on read, encrypts on flush - 5 unit tests: roundtrip, different keys, key derivation, header format, password config Tests: 457 markbase-core (+5 new), 201 smb-server (658 total) --- data/auth.sqlite | Bin 81920 -> 81920 bytes markbase-core/src/vfs/encrypted_fs.rs | 344 ++++++++++++++++++++++++++ markbase-core/src/vfs/mod.rs | 1 + 3 files changed, 345 insertions(+) create mode 100644 markbase-core/src/vfs/encrypted_fs.rs diff --git a/data/auth.sqlite b/data/auth.sqlite index a702ab8ac8cde76ac2d119f51e0922739b8b10ce..dc05f5d27203f094dba4c07801c61f7fd0de5e95 100644 GIT binary patch delta 298 zcmZo@U~On%ogmG)XrhcWn91}Kw(f`0NFp-s+lZj&z z0|N_~0Ti{zD(e5=pRbYu32<#@Ech?K=>UrWb0znd$?O+CGI4L&tf;Vyi<5(yg)z%& zM@{ACy|>+2m@BzFC$m5J$i(HjSy8~BlZTsGnz6VvIXShsxN>vot5+h7!IK^SZvwmc z|9^hQ%iF*3GeT&2MgajnR(^j5{>%KE_$Trg@cRR;u;pixublkxkO()>5@x7T(+lhw GCjbC?bz-Xk delta 252 zcmZo@U~On%ogmFPccP3l, // 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;