diff --git a/src/carrier.rs b/src/carrier.rs new file mode 100644 index 0000000..00263f1 --- /dev/null +++ b/src/carrier.rs @@ -0,0 +1,993 @@ +use std::collections::HashMap; +use std::fmt::Debug; +use std::io::SeekFrom; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Result}; +use bytes::{Buf, Bytes, BytesMut}; +use dav_server::{ + davpath::DavPath, + fs::{ + DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, + OpenOptions, ReadDirMeta, + }, +}; +use futures_util::stream; +use rand::rngs::OsRng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use tracing::error; +use zeroize::Zeroizing; + +use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; +use crate::storage::Database; +use crate::vfs::{is_leak_file, SanctumDirEntry, SanctumMetaData}; + +pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR"; +pub const CARRIER_VERSION: u32 = 1; + +/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CarrierManifest { + pub magic: [u8; 8], + pub version: u32, + pub total_blocks: u32, + pub free_blocks: Vec, + pub next_inode_id: i64, + pub inodes: HashMap, +} + +impl CarrierManifest { + pub fn new(total_blocks: u32) -> Self { + let mut inodes = HashMap::new(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // Inode 1 ist das Wurzelverzeichnis '/' + inodes.insert( + 1, + CarrierInode { + id: 1, + parent_id: None, + name: String::new(), + is_dir: true, + size: 0, + created_at: now, + modified_at: now, + blocks: Vec::new(), + }, + ); + + // Block 0 ist für das Manifest reserviert. Nutzblöcke sind 1..total_blocks-1. + let free_blocks = (1..total_blocks).collect(); + + Self { + magic: *CARRIER_MAGIC, + version: CARRIER_VERSION, + total_blocks, + free_blocks, + next_inode_id: 2, + inodes, + } + } +} + +/// Inode-Eintrag für Dateien und Verzeichnisse im Carrier-Dateisystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CarrierInode { + pub id: i64, + pub parent_id: Option, + pub name: String, + pub is_dir: bool, + pub size: u64, + pub created_at: u64, + pub modified_at: u64, + pub blocks: Vec, // Indizes der 1-MB-Blöcke innerhalb der Trägerdatei (1..total_blocks-1) +} + +pub const CARRIER_BLOCK_PAYLOAD_SIZE: usize = CHUNK_SIZE - 64; + +/// Liest einen Carrier-Block mit Zwei-Schichten-AEAD: +/// 1. Äußere Entschlüsselung mit dek_outer (DEK_0) +/// 2. Innere Entschlüsselung mit dek_inner (DEK_1) +pub fn read_carrier_block( + db: &Database, + carrier_node_id: i64, + block_idx: u32, + dek_outer: &[u8; 32], + dek_inner: &[u8; 32], + format_version: u32, +) -> Result> { + let chunk_rec = db + .read_chunk(carrier_node_id, block_idx)? + .ok_or_else(|| anyhow::anyhow!("Carrier-Chunk #{} nicht gefunden", block_idx))?; + + // 1. Äußere Schicht entschlüsseln (mit dek_outer = DEK_0) + let outer_decrypted = decrypt_chunk( + dek_outer, + carrier_node_id, + block_idx, + &chunk_rec.ciphertext, + &chunk_rec.nonce, + &chunk_rec.tag, + format_version, + )?; + + // Das outer_decrypted enthält: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding + if outer_decrypted.len() < 32 { + bail!( + "Carrier-Block #{}: Äußere Nutzdaten zu kurz (< 32 Bytes)", + block_idx + ); + } + + let mut inner_nonce = [0u8; 12]; + inner_nonce.copy_from_slice(&outer_decrypted[0..12]); + let mut inner_tag = [0u8; 16]; + inner_tag.copy_from_slice(&outer_decrypted[12..28]); + let inner_ct_len = u32::from_le_bytes(outer_decrypted[28..32].try_into().unwrap()) as usize; + if 32 + inner_ct_len > outer_decrypted.len() { + bail!( + "Carrier-Block #{}: Ungültige innere Ciphertext-Länge {} (verfügbar: {})", + block_idx, + inner_ct_len, + outer_decrypted.len() - 32 + ); + } + let inner_ciphertext = &outer_decrypted[32..32 + inner_ct_len]; + + // 2. Innere Schicht entschlüsseln (mit dek_inner = DEK_1) + let inner_plaintext = decrypt_chunk( + dek_inner, + carrier_node_id, + block_idx, + inner_ciphertext, + &inner_nonce, + &inner_tag, + format_version, + )?; + + Ok(inner_plaintext) +} + +/// Schreibt einen Carrier-Block mit Zwei-Schichten-AEAD: +/// 1. Innere Verschlüsselung mit dek_inner (DEK_1) +/// 2. Auffüllen auf exakt CHUNK_SIZE mit CSPRNG-Zufallsdaten +/// 3. Äußere Verschlüsselung mit dek_outer (DEK_0) +pub fn write_carrier_block( + db: &Database, + carrier_node_id: i64, + block_idx: u32, + dek_outer: &[u8; 32], + dek_inner: &[u8; 32], + plaintext: &[u8], + format_version: u32, +) -> Result<()> { + // 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1) + let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk( + dek_inner, + carrier_node_id, + block_idx, + plaintext, + format_version, + )?; + + let inner_ct_len = inner_ct.len() as u32; + + // 2. Äußere Nutzlast vorbereiten: Exakt CHUNK_SIZE (1 MB) mit CSPRNG-Rauschen + // Format: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding + let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut outer_plaintext); + + outer_plaintext[0..12].copy_from_slice(&inner_nonce); + outer_plaintext[12..28].copy_from_slice(&inner_tag); + outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes()); + let ct_end = 32 + inner_ct.len(); + if ct_end > CHUNK_SIZE { + bail!( + "Carrier-Block #{}: Payload überschreitet Blockgröße ({} > {})", + block_idx, + ct_end, + CHUNK_SIZE + ); + } + outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); + + // 3. Äußere Schicht verschlüsseln (mit dek_outer = DEK_0) + let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk( + dek_outer, + carrier_node_id, + block_idx, + &outer_plaintext, + format_version, + )?; + + // 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks) + db.write_chunk( + carrier_node_id, + block_idx, + &outer_nonce, + &outer_tag, + &outer_ct, + )?; + + Ok(()) +} + +/// Überschreibt einen freigegebenen Carrier-Block mit frischem kryptografischen Rauschen +/// (unter dek_outer verschlüsselt), sodass er weiterhin als 100% gültiger DEK_0-Chunk authentifiziert. +pub fn shred_carrier_block( + db: &Database, + carrier_node_id: i64, + block_idx: u32, + dek_outer: &[u8; 32], + format_version: u32, +) -> Result<()> { + let mut noise = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut noise); + + let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk( + dek_outer, + carrier_node_id, + block_idx, + &noise, + format_version, + )?; + + db.write_chunk( + carrier_node_id, + block_idx, + &outer_nonce, + &outer_tag, + &outer_ct, + )?; + + Ok(()) +} + +/// Interner Zustand des Carrier-Dateisystems. +pub struct CarrierFsInner { + pub db: Database, + pub carrier_node_id: i64, + pub dek_outer: Arc>, + pub dek_inner: Arc>, + pub format_version: u32, + pub anti_leak: bool, + pub manifest: CarrierManifest, + pub last_activity: Arc, +} + +impl CarrierFsInner { + pub fn save_manifest(&mut self) -> Result<()> { + let manifest_bytes = serde_json::to_vec(&self.manifest)?; + write_carrier_block( + &self.db, + self.carrier_node_id, + 0, + &self.dek_outer, + &self.dek_inner, + &manifest_bytes, + self.format_version, + )?; + Ok(()) + } + + pub fn allocate_block(&mut self) -> Result { + self.manifest + .free_blocks + .pop() + .ok_or(FsError::InsufficientStorage) + } + + pub fn free_block(&mut self, block_idx: u32) -> Result<(), FsError> { + let _ = shred_carrier_block( + &self.db, + self.carrier_node_id, + block_idx, + &self.dek_outer, + self.format_version, + ); + self.manifest.free_blocks.push(block_idx); + Ok(()) + } + + pub fn resolve_path(&self, raw_path: &str) -> Option { + let trimmed = raw_path.trim_matches('/'); + if trimmed.is_empty() { + return self.manifest.inodes.get(&1).cloned(); + } + + let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect(); + let mut current_id = 1i64; + + for (idx, segment) in segments.iter().enumerate() { + let child = self.manifest.inodes.values().find(|inode| { + inode.parent_id == Some(current_id) && inode.name.as_str() == *segment + })?; + + if idx + 1 < segments.len() && !child.is_dir { + return None; + } + current_id = child.id; + } + + self.manifest.inodes.get(¤t_id).cloned() + } + + pub fn split_parent_and_name<'a>(&self, path: &'a str) -> (&'a str, &'a str) { + let trimmed = path.trim_matches('/'); + match trimmed.rfind('/') { + Some(pos) => (&trimmed[..pos], &trimmed[pos + 1..]), + None => ("", trimmed), + } + } +} + +/// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault). +#[derive(Clone)] +pub struct CarrierFs { + inner: Arc>, +} + +impl CarrierFs { + /// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei. + pub fn load( + db: Database, + carrier_node_id: i64, + dek_outer: Arc>, + dek_inner: Arc>, + format_version: u32, + anti_leak: bool, + ) -> Result { + let manifest_bytes = read_carrier_block( + &db, + carrier_node_id, + 0, + &dek_outer, + &dek_inner, + format_version, + )?; + + let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?; + if manifest.magic != *CARRIER_MAGIC { + bail!("Ungültige Carrier-Magic-Bytes in Block 0"); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let inner = CarrierFsInner { + db, + carrier_node_id, + dek_outer, + dek_inner, + format_version, + anti_leak, + manifest, + last_activity: Arc::new(AtomicU64::new(now)), + }; + + Ok(Self { + inner: Arc::new(Mutex::new(inner)), + }) + } + + pub fn last_activity(&self) -> Arc { + let inner = self.inner.lock().unwrap(); + inner.last_activity.clone() + } + + pub fn touch(&self) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let inner = self.inner.lock().unwrap(); + inner.last_activity.store(now, Ordering::Relaxed); + } + + fn path_to_str(path: &DavPath) -> String { + String::from_utf8_lossy(path.as_bytes()).to_string() + } +} + +impl DavFileSystem for CarrierFs { + fn open<'a>( + &'a self, + path: &'a DavPath, + options: OpenOptions, + ) -> FsFuture<'a, Box> { + Box::pin(async move { + self.touch(); + let path_str = Self::path_to_str(path); + + let mut inner = self.inner.lock().unwrap(); + let (parent_path, file_name) = inner.split_parent_and_name(&path_str); + + if inner.anti_leak && is_leak_file(file_name) { + if options.create + || options.create_new + || options.write + || options.append + || options.truncate + { + return Err(FsError::Forbidden); + } + } + + let existing_node = inner.resolve_path(&path_str); + + let inode = match existing_node { + Some(mut n) => { + if n.is_dir && (options.write || options.append) { + return Err(FsError::Forbidden); + } + if options.create_new { + return Err(FsError::Exists); + } + + if options.truncate { + for b in n.blocks.drain(..) { + let _ = shred_carrier_block( + &inner.db, + inner.carrier_node_id, + b, + &inner.dek_outer, + inner.format_version, + ); + inner.manifest.free_blocks.push(b); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + n.size = 0; + n.modified_at = now; + inner.manifest.inodes.insert(n.id, n.clone()); + let _ = inner.save_manifest(); + } + n + } + None => { + if options.create || options.create_new { + let parent = inner.resolve_path(parent_path).ok_or(FsError::NotFound)?; + if !parent.is_dir { + return Err(FsError::Forbidden); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let new_id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + + let new_inode = CarrierInode { + id: new_id, + parent_id: Some(parent.id), + name: file_name.to_string(), + is_dir: false, + size: 0, + created_at: now, + modified_at: now, + blocks: Vec::new(), + }; + + inner.manifest.inodes.insert(new_id, new_inode.clone()); + let _ = inner.save_manifest(); + new_inode + } else { + return Err(FsError::NotFound); + } + } + }; + + drop(inner); + + let file = CarrierFile::new(inode, self.inner.clone()); + Ok(Box::new(file) as Box) + }) + } + + fn read_dir<'a>( + &'a self, + path: &'a DavPath, + _meta: ReadDirMeta, + ) -> FsFuture<'a, FsStream>> { + Box::pin(async move { + self.touch(); + let path_str = Self::path_to_str(path); + let inner = self.inner.lock().unwrap(); + + let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?; + if !node.is_dir { + return Err(FsError::Forbidden); + } + + let entries: Vec, FsError>> = inner + .manifest + .inodes + .values() + .filter(|child| child.parent_id == Some(node.id)) + .filter(|child| !inner.anti_leak || !is_leak_file(&child.name)) + .map(|child| { + Ok(Box::new(SanctumDirEntry { + name: child.name.clone(), + meta: SanctumMetaData { + is_dir: child.is_dir, + size: child.size, + created_at: UNIX_EPOCH + Duration::from_secs(child.created_at), + modified_at: UNIX_EPOCH + Duration::from_secs(child.modified_at), + }, + }) as Box) + }) + .collect(); + + Ok(Box::pin(stream::iter(entries)) as FsStream>) + }) + } + + 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 inner = self.inner.lock().unwrap(); + let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?; + + let meta = SanctumMetaData { + is_dir: node.is_dir, + size: node.size, + created_at: UNIX_EPOCH + Duration::from_secs(node.created_at), + modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), + }; + + Ok(Box::new(meta) as Box) + }) + } + + fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { + self.metadata(path) + } + + 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 mut inner = self.inner.lock().unwrap(); + let (parent_path, dir_name) = inner.split_parent_and_name(&path_str); + + if inner.anti_leak && is_leak_file(dir_name) { + return Err(FsError::Forbidden); + } + + if inner.resolve_path(&path_str).is_some() { + return Err(FsError::Exists); + } + + let parent = inner.resolve_path(parent_path).ok_or(FsError::NotFound)?; + if !parent.is_dir { + return Err(FsError::Forbidden); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let new_id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + + let new_dir = CarrierInode { + id: new_id, + parent_id: Some(parent.id), + name: dir_name.to_string(), + is_dir: true, + size: 0, + created_at: now, + modified_at: now, + blocks: Vec::new(), + }; + + inner.manifest.inodes.insert(new_id, new_dir); + inner + .save_manifest() + .map_err(|_| FsError::GeneralFailure)?; + + Ok(()) + }) + } + + 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 mut inner = self.inner.lock().unwrap(); + + let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?; + if !node.is_dir { + return Err(FsError::Forbidden); + } + if node.id == 1 { + // Wurzelknoten darf nicht gelöscht werden + return Err(FsError::Forbidden); + } + + // Prüfe, ob das Verzeichnis leer ist + let has_children = inner + .manifest + .inodes + .values() + .any(|child| child.parent_id == Some(node.id)); + + if has_children { + return Err(FsError::Forbidden); + } + + inner.manifest.inodes.remove(&node.id); + inner + .save_manifest() + .map_err(|_| FsError::GeneralFailure)?; + + Ok(()) + }) + } + + 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 mut inner = self.inner.lock().unwrap(); + + let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?; + if node.is_dir { + return Err(FsError::Forbidden); + } + + for b in node.blocks { + let _ = shred_carrier_block( + &inner.db, + inner.carrier_node_id, + b, + &inner.dek_outer, + inner.format_version, + ); + inner.manifest.free_blocks.push(b); + } + + inner.manifest.inodes.remove(&node.id); + inner + .save_manifest() + .map_err(|_| FsError::GeneralFailure)?; + + Ok(()) + }) + } + + fn rename<'a>(&'a self, from: &'a DavPath, 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); + + let mut inner = self.inner.lock().unwrap(); + let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?; + + let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str); + if inner.anti_leak && is_leak_file(to_name) { + return Err(FsError::Forbidden); + } + + let to_parent = inner + .resolve_path(to_parent_path) + .ok_or(FsError::NotFound)?; + if !to_parent.is_dir { + return Err(FsError::Forbidden); + } + + // Falls Zieldatei existiert und kein Verzeichnis ist: überschreiben + if let Some(dest) = inner.resolve_path(&to_str) { + if dest.is_dir { + return Err(FsError::Forbidden); + } + for b in dest.blocks { + let _ = shred_carrier_block( + &inner.db, + inner.carrier_node_id, + b, + &inner.dek_outer, + inner.format_version, + ); + inner.manifest.free_blocks.push(b); + } + inner.manifest.inodes.remove(&dest.id); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if let Some(inode) = inner.manifest.inodes.get_mut(&node.id) { + inode.parent_id = Some(to_parent.id); + inode.name = to_name.to_string(); + inode.modified_at = now; + } + + inner + .save_manifest() + .map_err(|_| FsError::GeneralFailure)?; + + Ok(()) + }) + } +} + +/// Datei-Handle für Dateien innerhalb des Carrier-Dateisystems mit Streaming und Chunk-Pufferung. +pub struct CarrierFile { + inode_id: i64, + file_size: u64, + cursor: u64, + blocks: Vec, + inner_fs: Arc>, + meta: SanctumMetaData, + // (block_index_in_file, decrypted_payload, is_dirty) + cached_block: Option<(usize, Vec, bool)>, +} + +impl Debug for CarrierFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CarrierFile") + .field("inode_id", &self.inode_id) + .field("file_size", &self.file_size) + .field("cursor", &self.cursor) + .finish() + } +} + +impl CarrierFile { + pub fn new(inode: CarrierInode, inner_fs: Arc>) -> Self { + let meta = SanctumMetaData { + is_dir: inode.is_dir, + size: inode.size, + created_at: UNIX_EPOCH + Duration::from_secs(inode.created_at), + modified_at: UNIX_EPOCH + Duration::from_secs(inode.modified_at), + }; + + Self { + inode_id: inode.id, + file_size: inode.size, + cursor: 0, + blocks: inode.blocks, + inner_fs, + meta, + cached_block: None, + } + } + + fn touch(&self) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let inner = self.inner_fs.lock().unwrap(); + inner.last_activity.store(now, Ordering::Relaxed); + } + + fn flush_cached_block(&mut self) -> Result<(), FsError> { + if let Some((block_idx_in_file, ref data, true)) = self.cached_block { + let mut inner = self.inner_fs.lock().unwrap(); + + // Allokiere Blöcke bis zum aktuellen Index falls nötig + while self.blocks.len() <= block_idx_in_file { + let new_block = inner.allocate_block()?; + self.blocks.push(new_block); + } + + let carrier_block_idx = self.blocks[block_idx_in_file]; + write_carrier_block( + &inner.db, + inner.carrier_node_id, + carrier_block_idx, + &inner.dek_outer, + &inner.dek_inner, + data, + inner.format_version, + ) + .map_err(|e| { + error!("Fehler beim Schreiben des Carrier-Blocks: {e}"); + FsError::GeneralFailure + })?; + + if let Some((_, _, ref mut dirty)) = self.cached_block { + *dirty = false; + } + } + Ok(()) + } + + fn ensure_block_loaded(&mut self, block_idx_in_file: usize) -> Result<&mut Vec, FsError> { + if let Some((cached_idx, _, _)) = self.cached_block { + if cached_idx == block_idx_in_file { + return Ok(&mut self.cached_block.as_mut().unwrap().1); + } + } + + self.flush_cached_block()?; + + let data = if block_idx_in_file < self.blocks.len() { + let carrier_block_idx = self.blocks[block_idx_in_file]; + let inner = self.inner_fs.lock().unwrap(); + match read_carrier_block( + &inner.db, + inner.carrier_node_id, + carrier_block_idx, + &inner.dek_outer, + &inner.dek_inner, + inner.format_version, + ) { + Ok(bytes) => bytes, + Err(e) => { + error!("Fehler beim Lesen des Carrier-Blocks: {e}"); + return Err(FsError::GeneralFailure); + } + } + } else { + Vec::new() + }; + + self.cached_block = Some((block_idx_in_file, data, false)); + Ok(&mut self.cached_block.as_mut().unwrap().1) + } +} + +impl DavFile for CarrierFile { + fn metadata(&mut self) -> FsFuture<'_, Box> { + let meta = self.meta.clone(); + Box::pin(async move { Ok(Box::new(meta) as Box) }) + } + + fn read_bytes(&mut self, count: usize) -> FsFuture<'_, Bytes> { + self.touch(); + Box::pin(async move { + if self.cursor >= self.file_size { + return Ok(Bytes::new()); + } + + let available = (self.file_size - self.cursor) as usize; + let to_read = count.min(available); + let mut result = BytesMut::with_capacity(to_read); + let mut remaining = to_read; + + while remaining > 0 { + let block_idx = (self.cursor / CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize; + let offset_in_block = (self.cursor % CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize; + let space_in_block = CARRIER_BLOCK_PAYLOAD_SIZE - offset_in_block; + let bytes_from_block = remaining.min(space_in_block); + + let block_data = self.ensure_block_loaded(block_idx)?; + + if offset_in_block < block_data.len() { + let end = (offset_in_block + bytes_from_block).min(block_data.len()); + result.extend_from_slice(&block_data[offset_in_block..end]); + let actually_read = end - offset_in_block; + self.cursor += actually_read as u64; + remaining -= actually_read; + if actually_read < bytes_from_block { + break; + } + } else { + break; + } + } + + Ok(result.freeze()) + }) + } + + fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> { + self.touch(); + Box::pin(async move { + let mut src = &buf[..]; + + while !src.is_empty() { + let block_idx = (self.cursor / CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize; + let offset_in_block = (self.cursor % CARRIER_BLOCK_PAYLOAD_SIZE as u64) as usize; + let space_in_block = CARRIER_BLOCK_PAYLOAD_SIZE - offset_in_block; + let to_write = src.len().min(space_in_block); + + let block_data = self.ensure_block_loaded(block_idx)?; + + if block_data.len() < offset_in_block { + block_data.resize(offset_in_block, 0); + } + if block_data.len() < offset_in_block + to_write { + block_data.resize(offset_in_block + to_write, 0); + } + + block_data[offset_in_block..offset_in_block + to_write] + .copy_from_slice(&src[..to_write]); + + if let Some((_, _, ref mut dirty)) = self.cached_block { + *dirty = true; + } + + self.cursor += to_write as u64; + if self.cursor > self.file_size { + self.file_size = self.cursor; + } + + if self + .cached_block + .as_ref() + .map(|(_, d, _)| d.len() >= CARRIER_BLOCK_PAYLOAD_SIZE) + .unwrap_or(false) + { + self.flush_cached_block()?; + } + + src = &src[to_write..]; + } + + Ok(()) + }) + } + + fn write_buf(&mut self, mut buf: Box) -> FsFuture<'_, ()> { + let bytes = buf.copy_to_bytes(buf.remaining()); + self.write_bytes(bytes) + } + + 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, + SeekFrom::End(offset) => self.file_size as i64 + offset, + SeekFrom::Current(offset) => self.cursor as i64 + offset, + }; + + if new_cursor < 0 { + return Err(FsError::GeneralFailure); + } + + self.cursor = new_cursor as u64; + Ok(self.cursor) + }) + } + + fn flush(&mut self) -> FsFuture<'_, ()> { + self.touch(); + Box::pin(async move { + self.flush_cached_block()?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut inner = self.inner_fs.lock().unwrap(); + if let Some(inode) = inner.manifest.inodes.get_mut(&self.inode_id) { + inode.size = self.file_size; + inode.blocks = self.blocks.clone(); + inode.modified_at = now; + } + + inner + .save_manifest() + .map_err(|_| FsError::GeneralFailure)?; + + self.meta.size = self.file_size; + self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); + Ok(()) + }) + } +} diff --git a/src/crypto.rs b/src/crypto.rs index c7cc418..20541ce 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -78,11 +78,10 @@ pub fn generate_salt() -> [u8; 16] { salt } -/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM. -/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. -pub fn wrap_dek( +/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM. +pub fn wrap_key_payload( kek: &[u8; 32], - dek: &[u8; 32], + payload: &[u8], ) -> Result<(Vec, [u8; 12], [u8; 16])> { let cipher = Aes256Gcm::new_from_slice(kek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; @@ -91,10 +90,10 @@ pub fn wrap_dek( OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); - let mut buffer = dek.to_vec(); + let mut buffer = payload.to_vec(); let tag = cipher .encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) - .map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?; + .map_err(|e| anyhow::anyhow!("Key-Wrapping fehlgeschlagen: {e}"))?; let mut tag_bytes = [0u8; 16]; tag_bytes.copy_from_slice(tag.as_slice()); @@ -102,6 +101,36 @@ pub fn wrap_dek( Ok((buffer, nonce_bytes, tag_bytes)) } +/// Entschlüsselt beliebige Schlüsseldaten via AES-256-GCM und validiert die Authentizität. +pub fn unwrap_key_payload( + kek: &[u8; 32], + wrapped_payload: &[u8], + nonce_bytes: &[u8; 12], + tag_bytes: &[u8; 16], +) -> Result>> { + let cipher = Aes256Gcm::new_from_slice(kek) + .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; + + let nonce = Nonce::from_slice(nonce_bytes); + let tag = Tag::from_slice(tag_bytes); + + let mut buffer = wrapped_payload.to_vec(); + cipher + .decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag) + .map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?; + + Ok(Zeroizing::new(buffer)) +} + +/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM. +/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. +pub fn wrap_dek( + kek: &[u8; 32], + dek: &[u8; 32], +) -> Result<(Vec, [u8; 12], [u8; 16])> { + wrap_key_payload(kek, dek) +} + /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität. pub fn unwrap_dek( kek: &[u8; 32], @@ -109,31 +138,47 @@ pub fn unwrap_dek( nonce_bytes: &[u8; 12], tag_bytes: &[u8; 16], ) -> Result> { - if wrapped_dek.len() != 32 { - bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len()); + let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?; + if payload.len() < 32 { + bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len()); } - let cipher = Aes256Gcm::new_from_slice(kek) - .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; - - let nonce = Nonce::from_slice(nonce_bytes); - let tag = Tag::from_slice(tag_bytes); - - let mut buffer = wrapped_dek.to_vec(); - cipher - .decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag) - .map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?; - let mut dek = Zeroizing::new([0u8; 32]); - dek.copy_from_slice(&buffer); + dek.copy_from_slice(&payload[0..32]); Ok(dek) } +/// Verschlüsselt den Slot-0 Payload (32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian). +pub fn wrap_slot0_payload( + kek: &[u8; 32], + dek_0: &[u8; 32], + carrier_node_id: i64, +) -> Result<(Vec, [u8; 12], [u8; 16])> { + let mut payload = Vec::with_capacity(40); + payload.extend_from_slice(dek_0); + payload.extend_from_slice(&carrier_node_id.to_le_bytes()); + wrap_key_payload(kek, &payload) +} + +/// Verschlüsselt den Slot-1 Payload für Modell A (32 Bytes DEK_1 || 32 Bytes DEK_0 || 8 Bytes carrier_node_id Little-Endian). +pub fn wrap_slot1_payload( + kek: &[u8; 32], + dek_1: &[u8; 32], + dek_0: &[u8; 32], + carrier_node_id: i64, +) -> Result<(Vec, [u8; 12], [u8; 16])> { + let mut payload = Vec::with_capacity(72); + payload.extend_from_slice(dek_1); + payload.extend_from_slice(dek_0); + payload.extend_from_slice(&carrier_node_id.to_le_bytes()); + wrap_key_payload(kek, &payload) +} + /// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie -/// ein echter KDF/DEK-Slot. Dadurch sind Standard-Container von Containern mit Hidden Vault -/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). +/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von +/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). pub fn generate_dummy_slot() -> (Vec, [u8; 12], [u8; 16], [u8; 16]) { - let mut wrapped_dek = vec![0u8; 32]; + let mut wrapped_dek = vec![0u8; 72]; let mut nonce = [0u8; 12]; let mut tag = [0u8; 16]; let mut salt = [0u8; 16]; @@ -527,12 +572,43 @@ mod tests { let other_dek = generate_dek(); assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); - // Dummy-Slot hat korrekte Längen + // Dummy-Slot hat korrekte Längen (72 Bytes für Modell A) let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); - assert_eq!(dummy_dek.len(), 32); + assert_eq!(dummy_dek.len(), 72); assert_eq!(dummy_nonce.len(), 12); assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_salt.len(), 16); } + + #[test] + fn test_model_a_slot_payloads() { + let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap(); + let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap(); + let dek_0 = generate_dek(); + let dek_1 = generate_dek(); + let carrier_node_id = 42i64; + + // Slot 0 Payload: 40 Bytes + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + assert_eq!(wrapped_0.len(), 40); + + let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap(); + assert_eq!(unwrapped_0.len(), 40); + assert_eq!(&unwrapped_0[0..32], &*dek_0); + let recovered_cid_0 = i64::from_le_bytes(unwrapped_0[32..40].try_into().unwrap()); + assert_eq!(recovered_cid_0, carrier_node_id); + + // Slot 1 Payload: 72 Bytes + let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + assert_eq!(wrapped_1.len(), 72); + + let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap(); + assert_eq!(unwrapped_1.len(), 72); + assert_eq!(&unwrapped_1[0..32], &*dek_1); + assert_eq!(&unwrapped_1[32..64], &*dek_0); + let recovered_cid_1 = i64::from_le_bytes(unwrapped_1[64..72].try_into().unwrap()); + assert_eq!(recovered_cid_1, carrier_node_id); + } } + diff --git a/src/lib.rs b/src/lib.rs index 14852c2..e485f85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod carrier; pub mod crypto; pub mod mount; pub mod recovery; diff --git a/src/main.rs b/src/main.rs index 214ef9f..82388cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,8 @@ use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use sanctum::crypto::{ - dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek, - KdfParams, FORMAT_VERSION, + dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, + wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION, }; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::recovery::{ @@ -37,6 +37,14 @@ enum Commands { /// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability) #[arg(long, default_value_t = false)] with_hidden: bool, + + /// Dateiname der Trägerdatei im Decoy-Vault (Standard: backup_image.iso) + #[arg(long, default_value = "backup_image.iso")] + carrier_name: String, + + /// Größe der Trägerdatei (z. B. 100MB, 500MB, 1GB, 2GB; Standard: 1GB) + #[arg(long, default_value = "1GB")] + carrier_size: String, }, /// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten @@ -172,7 +180,30 @@ fn parse_drive_letter(s: &str) -> Result { Ok(ch.to_ascii_uppercase()) } -fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { +pub fn parse_size_string(s: &str) -> Result { + let trimmed = s.trim().to_uppercase(); + if let Some(num_str) = trimmed.strip_suffix("GB") { + let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?; + Ok(n * 1024 * 1024 * 1024) + } else if let Some(num_str) = trimmed.strip_suffix("MB") { + let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?; + Ok(n * 1024 * 1024) + } else if let Some(num_str) = trimmed.strip_suffix("KB") { + let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?; + Ok(n * 1024) + } else if let Ok(n) = trimmed.parse::() { + Ok(n) + } else { + bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s); + } +} + +fn handle_init( + container_path: &Path, + with_hidden: bool, + carrier_name: &str, + carrier_size_str: &str, +) -> Result<()> { if container_path.exists() { bail!( "Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", @@ -185,11 +216,17 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { println!("└─────────────────────────────────────────────────────────────┘"); println!(" Zieldatei: {}", container_path.display()); if with_hidden { - println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault")); + println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier")); + println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str)); } println!(); if with_hidden { + let carrier_size_bytes = parse_size_string(carrier_size_str)?; + if carrier_size_bytes < 2 * 1024 * 1024 { + bail!("Trägerdateigröße muss mindestens 2 MB betragen (Block 0 Manifest + mindestens 1 Datenblock)"); + } + println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───"); let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") .context("Fehler beim Einlesen des Passworts")?; @@ -233,21 +270,32 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { let dek_0 = generate_dek(); let dek_1 = generate_dek(); - ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM..."); - let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?; - let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?; + ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM..."); + let carrier_node_id = 3i64; + let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?; + let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?; - ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus..."); + ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str)); let db = Database::open(container_path) .context("Konnte SQLite-Containerdatei nicht anlegen")?; - db.init_schema_with_hidden( + db.init_schema_with_carrier( &salt_0, &kdf_params_0, &wrapped_dek_0, &nonce_0, &tag_0, - Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_1)), + Some(( + carrier_name, + carrier_size_bytes, + &salt_1, + &kdf_params_1, + &wrapped_dek_1, + &nonce_1, + &tag_1, + &dek_0, + &dek_1, + )), )?; db.checkpoint()?; @@ -257,14 +305,16 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { println!(); println!("┌─────────────────────────────────────────────────────────────┐"); - println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │"); + println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! │"); println!("└─────────────────────────────────────────────────────────────┘"); println!(); println!(" • Container: {}", container_path.display()); println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); + println!(" • Alibi-Datei: {} ({})", carrier_name, carrier_size_str); println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)"); - println!(" • Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung"); - println!(" • Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie"); + println!(" • Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei"); + println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0"); + println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte"); println!(); println!(" Befehl zum Einbinden als Netzlaufwerk:"); println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); @@ -307,13 +357,13 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM..."); let (wrapped_dek, header_nonce, header_tag) = - wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?; + wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?; ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus..."); let db = Database::open(container_path) .context("Konnte SQLite-Containerdatei nicht anlegen")?; - db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) + db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None) .context("Fehler bei der Schema-Initialisierung")?; db.checkpoint() @@ -406,11 +456,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() let db = Database::open(container_path) .context("Konnte Container-Datenbank nicht öffnen")?; - let (dek, target_slot_id) = if let Some(phrase) = recovery_key { + let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key { ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel..."); let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); - (d, 0) + (d, 0, None, None) } else { let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") .context("Fehler beim Einlesen des aktuellen Passworts")?; @@ -426,10 +476,12 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() .context("Konnte Container-Header nicht lesen")?; ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)..."); - let (d, _ver, slot_id) = meta + let keys = meta .authenticate(&old_password) .ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?; - (d, slot_id) + let d = keys.dek().clone(); + let slot_id = keys.slot_id(); + (d, slot_id, keys.carrier_dek(), keys.carrier_node_id()) }; println!(); @@ -454,8 +506,16 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<() let new_kek = derive_kek(&new_password, &new_salt, &new_params) .context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?; - let (new_wrapped_dek, new_nonce, new_tag) = - wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?; + let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 { + let c_dek = carrier_dek.unwrap_or_else(generate_dek); + let c_nid = carrier_node_id.unwrap_or(0); + wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid) + .context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")? + } else { + let c_nid = carrier_node_id.unwrap_or(0); + wrap_slot0_payload(&new_kek, &dek, c_nid) + .context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")? + }; ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus..."); db.update_slot_keys( @@ -580,9 +640,11 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> { .context("Konnte Container-Datenbank nicht öffnen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; - let (dek, _ver, slot_id) = meta + let keys = meta .authenticate(&password) .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; + let dek = keys.dek().clone(); + let slot_id = keys.slot_id(); let phrase = dek_to_mnemonic(&dek)?; if slot_id == 1 { @@ -612,10 +674,11 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { let meta = db.read_meta().context("Konnte Header nicht lesen")?; match meta.authenticate(&password) { - Some((d, _ver, slot_id)) => { + Some(keys) => { + let slot_id = keys.slot_id(); let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" }; println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc)); - Some(d) + Some(keys.dek().clone()) } None => { println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); @@ -641,8 +704,13 @@ async fn run() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Init { path, with_hidden } => { - handle_init(&path, with_hidden)?; + Commands::Init { + path, + with_hidden, + carrier_name, + carrier_size, + } => { + handle_init(&path, with_hidden, &carrier_name, &carrier_size)?; } Commands::Compact { path, pages } => { handle_compact(&path, pages)?; diff --git a/src/mount.rs b/src/mount.rs index dd55230..9cc9413 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -107,13 +107,17 @@ pub async fn mount_container( .read_meta() .context("Konnte Container-Header nicht lesen")?; - let (dek, version, vault_id) = match auth { + let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth { ContainerAuth::Password(ref password) => { ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); match meta.authenticate(password) { - Some((dek, ver, slot_id)) => { + Some(keys) => { ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); - (dek, ver, slot_id) + let vault_id = keys.slot_id(); + let ver = keys.version(); + let c_dek = keys.carrier_dek(); + let c_nid = keys.carrier_node_id(); + (keys.0, c_dek, c_nid, ver, vault_id) } None => { bail!("Ungültiges Master-Passwort oder Container beschädigt"); @@ -132,12 +136,20 @@ pub async fn mount_container( !children.is_empty() }; let vault_id = if is_hidden { 1 } else { 0 }; - (dek, meta.version, vault_id) + (dek, None, None, meta.version, vault_id) } }; - // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Vault-Routing) - let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); + // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing) + let fs = SanctumFs::with_carrier( + db.clone(), + dek, + carrier_dek, + carrier_node_id, + version, + anti_leak, + vault_id, + ); let last_activity = fs.last_activity(); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) diff --git a/src/storage.rs b/src/storage.rs index df11801..d5b2f7d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -8,8 +8,8 @@ use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use crate::crypto::{ - decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams, - FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, + decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload, + KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, }; use zeroize::Zeroizing; @@ -46,6 +46,36 @@ pub struct SlotMeta { pub header_tag: [u8; 16], } +/// Ergebnis einer erfolgreichen Authentifizierung eines Container-Slots. +/// Die Tupel-Struktur (0: DEK, 1: Version, 2: Slot-ID, 3: Carrier-DEK, 4: Carrier-Node-ID) +/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`). +#[derive(Clone)] +pub struct UnlockedKeys( + pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1) + pub u32, // 1: Formatversion + pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault) + pub Option>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden) + pub Option, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes) +); + +impl UnlockedKeys { + pub fn dek(&self) -> &Zeroizing<[u8; 32]> { + &self.0 + } + pub fn version(&self) -> u32 { + self.1 + } + pub fn slot_id(&self) -> u32 { + self.2 + } + pub fn carrier_dek(&self) -> Option> { + self.3.clone() + } + pub fn carrier_node_id(&self) -> Option { + self.4 + } +} + #[allow(dead_code)] #[derive(Debug, Clone)] pub struct ContainerMeta { @@ -62,17 +92,50 @@ impl ContainerMeta { /// Authentifiziert ein Master-Passwort über alle Header-Slots in strikt konstanter Zeit (Anti-Timing Side-Channel). /// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch. /// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id). - pub fn authenticate(&self, password: &str) -> Option<(Zeroizing<[u8; 32]>, u32, u32)> { + pub fn authenticate(&self, password: &str) -> Option { let mut matching = None; for slot in &self.slots { let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) .ok() .and_then(|kek| { - unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok() + unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok() }); - if let Some(dek) = res { + + if let Some(payload) = res { if matching.is_none() { - matching = Some((dek, slot.version, slot.slot_id)); + if slot.slot_id == 0 { + let mut dek = Zeroizing::new([0u8; 32]); + let carrier_node_id = if payload.len() >= 40 { + dek.copy_from_slice(&payload[0..32]); + let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap()); + if cid > 0 { Some(cid) } else { None } + } else if payload.len() >= 32 { + dek.copy_from_slice(&payload[0..32]); + None + } else { + continue; + }; + matching = Some(UnlockedKeys(dek, slot.version, 0, None, carrier_node_id)); + } else if slot.slot_id == 1 { + let mut dek_1 = Zeroizing::new([0u8; 32]); + let mut dek_0 = Zeroizing::new([0u8; 32]); + let (carrier_dek, carrier_node_id) = if payload.len() >= 72 { + dek_1.copy_from_slice(&payload[0..32]); + dek_0.copy_from_slice(&payload[32..64]); + let cid = i64::from_le_bytes(payload[64..72].try_into().unwrap()); + (Some(dek_0), if cid > 0 { Some(cid) } else { None }) + } else if payload.len() >= 64 { + dek_1.copy_from_slice(&payload[0..32]); + dek_0.copy_from_slice(&payload[32..64]); + (Some(dek_0), None) + } else if payload.len() >= 32 { + dek_1.copy_from_slice(&payload[0..32]); + (None, None) + } else { + continue; + }; + matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id)); + } } } } @@ -121,7 +184,7 @@ impl Database { } /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit. - pub fn authenticate_password(&self, password: &str) -> Result, u32, u32)>> { + pub fn authenticate_password(&self, password: &str) -> Result> { let meta = self.read_meta()?; Ok(meta.authenticate(password)) } @@ -217,6 +280,227 @@ impl Database { Ok(()) } + /// Initialisiert das Datenbankschema für Modell A (Alibi-Carrier / Steganografischer Tresor). + /// Legt die Trägerdatei im Decoy-Vault an und allokiert alle Carrier-Chunks mit initialem Rauschen. + /// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur: + /// - Slot 0: 40 Bytes gewrappter Payload (32B DEK_0 || 8B carrier_node_id) + /// - Slot 1: 72 Bytes gewrappter Payload (32B DEK_1 || 32B DEK_0 || 8B carrier_node_id oder CSPRNG-Rauschen) + /// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1) + /// - 0 unzugeordnete Chunks: 100% aller Chunks gehören zu legitimen Decoy-Inodes und authentifizieren fehlerfrei unter DEK_0! + pub fn init_schema_with_carrier( + &self, + salt_0: &[u8; 16], + kdf_params_0: &KdfParams, + wrapped_dek_0: &[u8], + header_nonce_0: &[u8; 12], + header_tag_0: &[u8; 16], + carrier_config: Option<( + &str, // carrier_name + u64, // carrier_size_bytes + &[u8; 16], // salt_1 + &KdfParams, // kdf_params_1 + &[u8], // wrapped_dek_1 (72B) + &[u8; 12], // header_nonce_1 + &[u8; 16], // header_tag_1 + &[u8; 32], // raw DEK_0 + &[u8; 32], // raw DEK_1 + )>, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS meta ( + slot_id INTEGER NOT NULL PRIMARY KEY, + magic BLOB NOT NULL, + version INTEGER NOT NULL, + kdf_salt BLOB NOT NULL, + kdf_params TEXT NOT NULL, + wrapped_dek BLOB NOT NULL, + header_nonce BLOB NOT NULL, + header_tag BLOB NOT NULL + ); + + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER, + name TEXT NOT NULL, + is_dir INTEGER NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS chunks ( + node_id INTEGER NOT NULL, + chunk_index INTEGER NOT NULL, + nonce BLOB NOT NULL, + tag BLOB NOT NULL, + ciphertext BLOB NOT NULL, + PRIMARY KEY (node_id, chunk_index), + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + );", + )?; + + // Slot 0 einfügen (Standard / Decoy Vault) + let params_json_0 = serde_json::to_string(kdf_params_0)?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + salt_0.as_slice(), + params_json_0, + wrapped_dek_0, + header_nonce_0.as_slice(), + header_tag_0.as_slice(), + ], + )?; + + let now = current_timestamp(); + // Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur) + conn.execute( + "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (1, NULL, '', 1, 0, ?1, ?2)", + params![now, now], + )?; + conn.execute( + "INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at) + VALUES (2, NULL, '', 1, 0, ?1, ?2)", + params![now, now], + )?; + + let carrier_node_id = if let Some(( + c_name, + c_size, + h_salt, + h_params, + h_wrapped, + h_nonce, + h_tag, + dek_0, + dek_1, + )) = carrier_config + { + // Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen + conn.execute( + "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) + VALUES (1, ?1, 0, ?2, ?3, ?4)", + params![c_name, c_size as i64, now, now], + )?; + let c_id = conn.last_insert_rowid(); + + // Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten) + let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32; + + // Slot 1 (Hidden Vault) einfügen + let params_json_1 = serde_json::to_string(h_params)?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + h_salt.as_slice(), + params_json_1, + h_wrapped, + h_nonce.as_slice(), + h_tag.as_slice(), + ], + )?; + + // Initialisiere CarrierManifest für Block 0 + let manifest = crate::carrier::CarrierManifest::new(total_blocks); + let manifest_bytes = serde_json::to_vec(&manifest)?; + + let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk( + dek_1, + c_id, + 0, + &manifest_bytes, + FORMAT_VERSION, + )?; + let inner_ct_len = inner_ct.len() as u32; + + let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut outer_plaintext); + + outer_plaintext[0..12].copy_from_slice(&inner_nonce); + outer_plaintext[12..28].copy_from_slice(&inner_tag); + outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes()); + let ct_end = 32 + inner_ct.len(); + if ct_end > CHUNK_SIZE { + bail!("Manifest-Payload zu groß für Block 0"); + } + outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); + + let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk( + dek_0, + c_id, + 0, + &outer_plaintext, + FORMAT_VERSION, + )?; + + conn.execute( + "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) + VALUES (?1, 0, ?2, ?3, ?4)", + params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct], + )?; + + // Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren + let mut chunk_stmt = conn.prepare( + "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + + let mut dummy_noise = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut dummy_noise); + + for b in 1..total_blocks { + let (ct, nonce, tag) = crate::crypto::encrypt_chunk( + dek_0, + c_id, + b, + &dummy_noise, + FORMAT_VERSION, + )?; + chunk_stmt.execute(params![ + c_id, + b, + nonce.as_slice(), + tag.as_slice(), + ct, + ])?; + } + + Some(c_id) + } else { + // Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A) + let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); + let dummy_params_json = serde_json::to_string(&KdfParams::default())?; + conn.execute( + "INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + MAGIC_BYTES.as_slice(), + FORMAT_VERSION, + dummy_salt.as_slice(), + dummy_params_json, + dummy_dek.as_slice(), + dummy_nonce.as_slice(), + dummy_tag.as_slice(), + ], + )?; + + None + }; + + Ok(carrier_node_id) + } + /// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault). /// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur: /// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen) @@ -912,7 +1196,11 @@ impl Database { /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log. pub fn checkpoint(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); - conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; + let _res: (i64, i64, i64) = conn.query_row( + "PRAGMA wal_checkpoint(TRUNCATE);", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; Ok(()) } diff --git a/src/vfs.rs b/src/vfs.rs index 3bd6b66..1aa0835 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -16,6 +16,7 @@ use futures_util::stream; use tracing::{debug, error, warn}; use zeroize::Zeroizing; +use crate::carrier::CarrierFs; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::{Database, NodeRecord}; @@ -386,6 +387,10 @@ impl DavFile for SanctumFile { pub struct SanctumFs { db: Database, dek: Arc>, + #[allow(dead_code)] + carrier_dek: Option>>, + carrier_node_id: Option, + carrier_fs: Option, format_version: u32, anti_leak: bool, vault_id: u32, @@ -412,14 +417,55 @@ impl SanctumFs { format_version: u32, anti_leak: bool, vault_id: u32, + ) -> Self { + Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id) + } + + pub fn with_carrier( + db: Database, + dek: Zeroizing<[u8; 32]>, + carrier_dek: Option>, + carrier_node_id: Option, + format_version: u32, + anti_leak: bool, + vault_id: u32, ) -> Self { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); + let dek_arc = Arc::new(dek); + let carrier_dek_arc = carrier_dek.map(Arc::new); + + let carrier_fs = if vault_id == 1 { + if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) { + match CarrierFs::load( + db.clone(), + c_nid, + c_dek.clone(), + dek_arc.clone(), + format_version, + anti_leak, + ) { + Ok(cfs) => Some(cfs), + Err(e) => { + warn!("CarrierFs konnte nicht initialisiert werden: {e}"); + None + } + } + } else { + None + } + } else { + None + }; + Self { db, - dek: Arc::new(dek), + dek: dek_arc, + carrier_dek: carrier_dek_arc, + carrier_node_id, + carrier_fs, format_version, anti_leak, vault_id, @@ -432,7 +478,11 @@ impl SanctumFs { } pub fn last_activity(&self) -> Arc { - self.last_activity.clone() + if let Some(ref cfs) = self.carrier_fs { + cfs.last_activity() + } else { + self.last_activity.clone() + } } pub fn is_anti_leak_enabled(&self) -> bool { @@ -440,11 +490,15 @@ impl SanctumFs { } 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); + if let Some(ref cfs) = self.carrier_fs { + cfs.touch(); + } else { + 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 { @@ -493,6 +547,10 @@ impl DavFileSystem for SanctumFs { path: &'a DavPath, options: OpenOptions, ) -> FsFuture<'a, Box> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.open(path, options); + } + Box::pin(async move { let path_str = Self::path_to_str(path); let (parent_path, file_name) = self.split_parent_and_name(&path_str); @@ -520,6 +578,11 @@ impl DavFileSystem for SanctumFs { let node = match existing_node { Some(n) => { + // Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt! + if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) { + return Err(FsError::Forbidden); + } + if n.is_dir && (options.write || options.append) { return Err(FsError::Forbidden); } @@ -579,8 +642,12 @@ impl DavFileSystem for SanctumFs { fn read_dir<'a>( &'a self, path: &'a DavPath, - _meta: ReadDirMeta, + meta: ReadDirMeta, ) -> FsFuture<'a, FsStream>> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.read_dir(path, meta); + } + Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); @@ -615,6 +682,10 @@ impl DavFileSystem for SanctumFs { } fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.metadata(path); + } + Box::pin(async move { let path_str = Self::path_to_str(path); if path_str != "/" && !path_str.is_empty() { @@ -636,10 +707,17 @@ impl DavFileSystem for SanctumFs { } fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.symlink_metadata(path); + } self.metadata(path) } fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.create_dir(path); + } + Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); @@ -668,6 +746,10 @@ impl DavFileSystem for SanctumFs { } fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.remove_dir(path); + } + Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); @@ -693,6 +775,10 @@ impl DavFileSystem for SanctumFs { } fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.remove_file(path); + } + Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); @@ -704,6 +790,11 @@ impl DavFileSystem for SanctumFs { return Err(FsError::Forbidden); } + // Schutz der Trägerdatei im Decoy Vault: Löschen verboten! + if self.carrier_node_id == Some(node.id) { + return Err(FsError::Forbidden); + } + self.db .delete_node(node.id) .map_err(|_| FsError::GeneralFailure)?; @@ -717,6 +808,10 @@ impl DavFileSystem for SanctumFs { from: &'a DavPath, to: &'a DavPath, ) -> FsFuture<'a, ()> { + if let Some(ref cfs) = self.carrier_fs { + return cfs.rename(from, to); + } + Box::pin(async move { self.touch(); let from_str = Self::path_to_str(from); @@ -726,6 +821,11 @@ impl DavFileSystem for SanctumFs { .resolve_path(&from_str)? .ok_or(FsError::NotFound)?; + // Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten! + if self.carrier_node_id == Some(node.id) { + return Err(FsError::Forbidden); + } + let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); if self.anti_leak && is_leak_file(to_name) { diff --git a/tests/carrier_model_a_test.rs b/tests/carrier_model_a_test.rs new file mode 100644 index 0000000..25b932d --- /dev/null +++ b/tests/carrier_model_a_test.rs @@ -0,0 +1,360 @@ +use std::path::PathBuf; + +use bytes::Bytes; +use dav_server::davpath::DavPath; +use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta}; +use rand::rngs::OsRng; +use rand::RngCore; + +use sanctum::crypto::{ + derive_kek, generate_dek, generate_salt, wrap_slot0_payload, + wrap_slot1_payload, KdfParams, CHUNK_SIZE, +}; +use sanctum::storage::Database; +use sanctum::verify::verify_container; +use sanctum::vfs::SanctumFs; + +fn temp_db_path(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let id: u64 = OsRng.next_u64(); + path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id)); + path +} + +#[tokio::test] +async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() { + let path = temp_db_path("carrier_accounting"); + let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB) + let carrier_name = "backup_image.iso"; + + let pass_decoy = "DecoyPassword2026!"; + let pass_hidden = "SuperSecretHiddenPassword2026!"; + + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + + let salt_0 = generate_salt(); + let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap(); + let dek_0 = generate_dek(); + + let salt_1 = generate_salt(); + let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap(); + let dek_1 = generate_dek(); + + let carrier_node_id = 3i64; + let (wrapped_0, nonce_0, tag_0) = + wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_1, nonce_1, tag_1) = + wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + + let db = Database::open(&path).expect("Open database"); + + let created_cid = db + .init_schema_with_carrier( + &salt_0, + &kdf_params, + &wrapped_0, + &nonce_0, + &tag_0, + Some(( + carrier_name, + carrier_size_bytes, + &salt_1, + &kdf_params, + &wrapped_1, + &nonce_1, + &tag_1, + &dek_0, + &dek_1, + )), + ) + .expect("Init carrier schema"); + + db.checkpoint().unwrap(); + assert_eq!(created_cid, Some(carrier_node_id)); + + // 1. Authentifizierung beider Passwörter + let meta = db.read_meta().unwrap(); + let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy"); + assert_eq!(auth_decoy.slot_id(), 0); + assert_eq!(**auth_decoy.dek(), *dek_0); + assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id)); + + let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden"); + assert_eq!(auth_hidden.slot_id(), 1); + assert_eq!(**auth_hidden.dek(), *dek_1); + assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0); + assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id)); + + // 2. Decoy Mount: Schutz der Alibi-Datei (backup_image.iso) + let decoy_fs = SanctumFs::with_carrier( + db.clone(), + auth_decoy.dek().clone(), + auth_decoy.carrier_dek(), + auth_decoy.carrier_node_id(), + auth_decoy.version(), + true, + 0, + ); + + let carrier_path = DavPath::new("/backup_image.iso").unwrap(); + + // Metadaten der Alibi-Datei im Decoy prüfen + let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta"); + assert_eq!(carrier_meta.len(), carrier_size_bytes); + assert!(!carrier_meta.is_dir()); + + // Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden + let write_opts = OpenOptions { + write: true, + ..Default::default() + }; + assert!( + matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)), + "Alibi-Datei darf nicht zum Schreiben geöffnet werden" + ); + + // Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden + assert!( + matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)), + "Alibi-Datei darf nicht gelöscht werden" + ); + + // Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden + let new_name = DavPath::new("/renamed.iso").unwrap(); + assert!( + matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)), + "Alibi-Datei darf nicht umbenannt werden" + ); + + // Alibi-Datei KANN im Decoy-Mount gelesen werden + let read_opts = OpenOptions { + read: true, + ..Default::default() + }; + let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read"); + let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk"); + assert_eq!(first_mb.len(), CHUNK_SIZE); + + // 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers + let hidden_fs = SanctumFs::with_carrier( + db.clone(), + auth_hidden.dek().clone(), + auth_hidden.carrier_dek(), + auth_hidden.carrier_node_id(), + auth_hidden.version(), + true, + 1, + ); + + // Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer) + let root_path = DavPath::new("/").unwrap(); + let mut stream = hidden_fs + .read_dir(&root_path, ReadDirMeta::None) + .await + .expect("Read dir root"); + use futures_util::StreamExt; + let mut entries = Vec::new(); + while let Some(item) = stream.next().await { + entries.push(item.unwrap().name()); + } + assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein"); + + // Ordner erstellen + let secret_dir = DavPath::new("/Classified").unwrap(); + hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir"); + + // Datei im Ordner anlegen und schreiben + let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap(); + let create_opts = OpenOptions { + create: true, + write: true, + ..Default::default() + }; + let mut secret_file = hidden_fs + .open(&secret_file_path, create_opts) + .await + .expect("Create secret file"); + + let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault"; + secret_file + .write_bytes(Bytes::from_static(secret_content)) + .await + .expect("Write secret content"); + secret_file.flush().await.expect("Flush secret file"); + drop(secret_file); + + // Datei lesen und verifizieren + let read_opts = OpenOptions { + read: true, + ..Default::default() + }; + let mut read_handle = hidden_fs + .open(&secret_file_path, read_opts) + .await + .expect("Open secret file for read"); + let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes"); + assert_eq!(&read_data[..], secret_content); + drop(read_handle); + + // Größere Binärdatei schreiben (über 2 MB = 2 Blöcke) + let big_file_path = DavPath::new("/Classified/payload.bin").unwrap(); + let mut big_file = hidden_fs + .open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() }) + .await + .expect("Create big file"); + + let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes + let mut payload = vec![0u8; payload_size]; + OsRng.fill_bytes(&mut payload); + + big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload"); + big_file.flush().await.expect("Flush big file"); + drop(big_file); + + // Datei zurücklesen und Bit-für-Bit verifizieren + let mut read_big = hidden_fs + .open(&big_file_path, OpenOptions { read: true, ..Default::default() }) + .await + .expect("Open big file"); + let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes"); + assert_eq!(read_big_bytes.len(), payload_size); + assert_eq!(&read_big_bytes[..], &payload[..]); + drop(read_big); + + // Datei umbenennen + let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap(); + hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file"); + assert!(hidden_fs.metadata(&big_file_path).await.is_err()); + assert!(hidden_fs.metadata(&renamed_path).await.is_ok()); + + // Datei löschen (Blöcke werden geshreddert und freigegeben) + hidden_fs.remove_file(&renamed_path).await.expect("Remove file"); + assert!(hidden_fs.metadata(&renamed_path).await.is_err()); + + // Checkpoint SQLite + db.checkpoint().unwrap(); + + // 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG + // Ein Angreifer besitzt nur das Decoy-Passwort (dek_0). + // Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch. + // ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln! + let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0"); + assert!( + report.is_healthy(), + "Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}", + report.errors + ); + assert_eq!( + report.corrupted_chunks, 0, + "Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!" + ); + assert_eq!( + report.orphan_nodes, 0, + "Es darf keine verwaisten Knoten geben!" + ); + + // Aufräumen + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn test_model_a_container_file_size_invariance() { + let path = temp_db_path("carrier_size_invariance"); + let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB + + let pass_decoy = "DecoyPass2026!"; + let pass_hidden = "HiddenPass2026!"; + + let kdf_params = KdfParams { + memory_cost: 1024, + time_cost: 1, + parallelism: 1, + }; + + let salt_0 = generate_salt(); + let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap(); + let dek_0 = generate_dek(); + + let salt_1 = generate_salt(); + let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap(); + let dek_1 = generate_dek(); + + let carrier_node_id = 3i64; + let (wrapped_0, nonce_0, tag_0) = + wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_1, nonce_1, tag_1) = + wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + + let db = Database::open(&path).expect("Open database"); + + db.init_schema_with_carrier( + &salt_0, + &kdf_params, + &wrapped_0, + &nonce_0, + &tag_0, + Some(( + "virtual_disk.vhdx", + carrier_size_bytes, + &salt_1, + &kdf_params, + &wrapped_1, + &nonce_1, + &tag_1, + &dek_0, + &dek_1, + )), + ) + .expect("Init carrier schema"); + + db.checkpoint().unwrap(); + + // Initiale Dateigröße messen + let initial_file_size = std::fs::metadata(&path).unwrap().len(); + assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen"); + + // Hidden Mount öffnen und 4 MB geheime Daten schreiben + let meta = db.read_meta().unwrap(); + let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden"); + + let hidden_fs = SanctumFs::with_carrier( + db.clone(), + auth_hidden.dek().clone(), + auth_hidden.carrier_dek(), + auth_hidden.carrier_node_id(), + auth_hidden.version(), + true, + 1, + ); + + let test_file = DavPath::new("/large_confidential.pdf").unwrap(); + let mut handle = hidden_fs + .open(&test_file, OpenOptions { create: true, write: true, ..Default::default() }) + .await + .expect("Open file"); + + let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB + OsRng.fill_bytes(&mut random_data); + handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB"); + handle.flush().await.expect("Flush 4MB"); + drop(handle); + + db.checkpoint().unwrap(); + + // Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen + let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len(); + + // Die Dateigröße auf der Festplatte DARF NICHT WACHSEN! + // Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben. + assert_eq!( + initial_file_size, size_after_hidden_writes, + "Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}", + initial_file_size, size_after_hidden_writes + ); + + let _ = std::fs::remove_file(&path); +}