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)
This commit is contained in:
344
markbase-core/src/vfs/encrypted_fs.rs
Normal file
344
markbase-core/src/vfs/encrypted_fs.rs
Normal file
@@ -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<u8>, // 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<dyn VfsBackend>,
|
||||
config: EncryptedVfsConfig,
|
||||
}
|
||||
|
||||
impl EncryptedVfs {
|
||||
pub fn new(inner: Box<dyn VfsBackend>, 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<u8> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<u8> {
|
||||
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<dyn VfsFile>,
|
||||
path: PathBuf,
|
||||
config: EncryptedVfsConfig,
|
||||
decrypted_data: Option<Vec<u8>>,
|
||||
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<usize, VfsError> {
|
||||
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<usize, 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()))?;
|
||||
|
||||
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<u64, VfsError> {
|
||||
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<VfsStat, VfsError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user