feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability)

This commit is contained in:
2026-09-09 20:22:00 +02:00
parent b8e4dcb614
commit 030ce6a1e5
8 changed files with 1971 additions and 73 deletions
+993
View File
@@ -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<u32>,
pub next_inode_id: i64,
pub inodes: HashMap<i64, CarrierInode>,
}
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<i64>,
pub name: String,
pub is_dir: bool,
pub size: u64,
pub created_at: u64,
pub modified_at: u64,
pub blocks: Vec<u32>, // 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<Vec<u8>> {
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<Zeroizing<[u8; 32]>>,
pub dek_inner: Arc<Zeroizing<[u8; 32]>>,
pub format_version: u32,
pub anti_leak: bool,
pub manifest: CarrierManifest,
pub last_activity: Arc<AtomicU64>,
}
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<u32, FsError> {
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<CarrierInode> {
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(&current_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<Mutex<CarrierFsInner>>,
}
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<Zeroizing<[u8; 32]>>,
dek_inner: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
) -> Result<Self> {
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<AtomicU64> {
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<dyn DavFile>> {
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<dyn DavFile>)
})
}
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
_meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
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<Result<Box<dyn DavDirEntry>, 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<dyn DavDirEntry>)
})
.collect();
Ok(Box::pin(stream::iter(entries)) as FsStream<Box<dyn DavDirEntry>>)
})
}
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
let 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<dyn DavMetaData>)
})
}
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
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<u32>,
inner_fs: Arc<Mutex<CarrierFsInner>>,
meta: SanctumMetaData,
// (block_index_in_file, decrypted_payload, is_dirty)
cached_block: Option<(usize, Vec<u8>, 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<Mutex<CarrierFsInner>>) -> 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<u8>, 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<dyn DavMetaData>> {
let meta = self.meta.clone();
Box::pin(async move { Ok(Box::new(meta) as Box<dyn DavMetaData>) })
}
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<dyn Buf + Send>) -> 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(())
})
}
}
+101 -25
View File
@@ -78,11 +78,10 @@ pub fn generate_salt() -> [u8; 16] {
salt salt
} }
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM. /// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück. pub fn wrap_key_payload(
pub fn wrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
dek: &[u8; 32], payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek) let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -91,10 +90,10 @@ pub fn wrap_dek(
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec(); let mut buffer = payload.to_vec();
let tag = cipher let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer) .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]; let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice()); tag_bytes.copy_from_slice(tag.as_slice());
@@ -102,6 +101,36 @@ pub fn wrap_dek(
Ok((buffer, nonce_bytes, tag_bytes)) 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<Zeroizing<Vec<u8>>> {
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>, [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. /// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek( pub fn unwrap_dek(
kek: &[u8; 32], kek: &[u8; 32],
@@ -109,31 +138,47 @@ pub fn unwrap_dek(
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> { ) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 { let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len()); 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]); let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer); dek.copy_from_slice(&payload[0..32]);
Ok(dek) 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>, [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>, [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 /// 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 /// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability). /// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) { pub fn generate_dummy_slot() -> (Vec<u8>, [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 nonce = [0u8; 12];
let mut tag = [0u8; 16]; let mut tag = [0u8; 16];
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
@@ -527,12 +572,43 @@ mod tests {
let other_dek = generate_dek(); let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none()); 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(); 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_nonce.len(), 12);
assert_eq!(dummy_tag.len(), 16); assert_eq!(dummy_tag.len(), 16);
assert_eq!(dummy_salt.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);
}
} }
+1
View File
@@ -1,3 +1,4 @@
pub mod carrier;
pub mod crypto; pub mod crypto;
pub mod mount; pub mod mount;
pub mod recovery; pub mod recovery;
+94 -26
View File
@@ -5,8 +5,8 @@ use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use sanctum::crypto::{ use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek, dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
KdfParams, FORMAT_VERSION, wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
}; };
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{ use sanctum::recovery::{
@@ -37,6 +37,14 @@ enum Commands {
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability) /// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
with_hidden: bool, 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 /// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
@@ -172,7 +180,30 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase()) Ok(ch.to_ascii_uppercase())
} }
fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> { pub fn parse_size_string(s: &str) -> Result<u64> {
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::<u64>() {
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() { if container_path.exists() {
bail!( bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.", "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!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display()); println!(" Zieldatei: {}", container_path.display());
if with_hidden { 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!(); println!();
if with_hidden { 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) ───"); println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ") let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?; .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_0 = generate_dek();
let dek_1 = generate_dek(); let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM..."); ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?; let carrier_node_id = 3i64;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?; 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) let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?; .context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_hidden( db.init_schema_with_carrier(
&salt_0, &salt_0,
&kdf_params_0, &kdf_params_0,
&wrapped_dek_0, &wrapped_dek_0,
&nonce_0, &nonce_0,
&tag_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()?; db.checkpoint()?;
@@ -257,14 +305,16 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!(); println!();
println!("┌─────────────────────────────────────────────────────────────┐"); println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │"); println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! ");
println!("└─────────────────────────────────────────────────────────────┘"); println!("└─────────────────────────────────────────────────────────────┘");
println!(); println!();
println!(" • Container: {}", container_path.display()); println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION); 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!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!("Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung"); println!("Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
println!("Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie"); println!("Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
println!(); println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:"); println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display()))); 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..."); ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) = 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..."); ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?; .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")?; .context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint() db.checkpoint()
@@ -406,11 +456,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path) let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?; .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..."); ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?; let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!"); ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0) (d, 0, None, None)
} else { } else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ") let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?; .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")?; .context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)..."); 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) .authenticate(&old_password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?; .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!(); 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) let new_kek = derive_kek(&new_password, &new_salt, &new_params)
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?; .context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
let (new_wrapped_dek, new_nonce, new_tag) = let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?; 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..."); ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
db.update_slot_keys( db.update_slot_keys(
@@ -580,9 +640,11 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
.context("Konnte Container-Datenbank nicht öffnen")?; .context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let (dek, _ver, slot_id) = meta let keys = meta
.authenticate(&password) .authenticate(&password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; .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)?; let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 { 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")?; let meta = db.read_meta().context("Konnte Header nicht lesen")?;
match meta.authenticate(&password) { 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)" }; 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)); println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""), ui::cyan(vault_desc));
Some(d) Some(keys.dek().clone())
} }
None => { None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️")); 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(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Init { path, with_hidden } => { Commands::Init {
handle_init(&path, with_hidden)?; path,
with_hidden,
carrier_name,
carrier_size,
} => {
handle_init(&path, with_hidden, &carrier_name, &carrier_size)?;
} }
Commands::Compact { path, pages } => { Commands::Compact { path, pages } => {
handle_compact(&path, pages)?; handle_compact(&path, pages)?;
+18 -6
View File
@@ -107,13 +107,17 @@ pub async fn mount_container(
.read_meta() .read_meta()
.context("Konnte Container-Header nicht lesen")?; .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) => { ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)..."); ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
match meta.authenticate(password) { match meta.authenticate(password) {
Some((dek, ver, slot_id)) => { Some(keys) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!"); 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 => { None => {
bail!("Ungültiges Master-Passwort oder Container beschädigt"); bail!("Ungültiges Master-Passwort oder Container beschädigt");
@@ -132,12 +136,20 @@ pub async fn mount_container(
!children.is_empty() !children.is_empty()
}; };
let vault_id = if is_hidden { 1 } else { 0 }; 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) // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_vault(db.clone(), dek, version, anti_leak, vault_id); 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 last_activity = fs.last_activity();
let dav_server = DavHandler::builder() let dav_server = DavHandler::builder()
.filesystem(Box::new(fs)) .filesystem(Box::new(fs))
+296 -8
View File
@@ -8,8 +8,8 @@ use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{ use crate::crypto::{
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams, decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
}; };
use zeroize::Zeroizing; use zeroize::Zeroizing;
@@ -46,6 +46,36 @@ pub struct SlotMeta {
pub header_tag: [u8; 16], 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<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
pub Option<i64>, // 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<Zeroizing<[u8; 32]>> {
self.3.clone()
}
pub fn carrier_node_id(&self) -> Option<i64> {
self.4
}
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ContainerMeta { 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). /// 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. /// 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). /// 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<UnlockedKeys> {
let mut matching = None; let mut matching = None;
for slot in &self.slots { for slot in &self.slots {
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
.ok() .ok()
.and_then(|kek| { .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() { 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. /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<(Zeroizing<[u8; 32]>, u32, u32)>> { pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?; let meta = self.read_meta()?;
Ok(meta.authenticate(password)) Ok(meta.authenticate(password))
} }
@@ -217,6 +280,227 @@ impl Database {
Ok(()) 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<Option<i64>> {
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). /// 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: /// 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) /// - 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. /// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> { pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap(); 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(()) Ok(())
} }
+108 -8
View File
@@ -16,6 +16,7 @@ use futures_util::stream;
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use zeroize::Zeroizing; use zeroize::Zeroizing;
use crate::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord}; use crate::storage::{Database, NodeRecord};
@@ -386,6 +387,10 @@ impl DavFile for SanctumFile {
pub struct SanctumFs { pub struct SanctumFs {
db: Database, db: Database,
dek: Arc<Zeroizing<[u8; 32]>>, dek: Arc<Zeroizing<[u8; 32]>>,
#[allow(dead_code)]
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
carrier_node_id: Option<i64>,
carrier_fs: Option<CarrierFs>,
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, vault_id: u32,
@@ -412,14 +417,55 @@ impl SanctumFs {
format_version: u32, format_version: u32,
anti_leak: bool, anti_leak: bool,
vault_id: u32, 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<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
vault_id: u32,
) -> Self { ) -> Self {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .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 { Self {
db, db,
dek: Arc::new(dek), dek: dek_arc,
carrier_dek: carrier_dek_arc,
carrier_node_id,
carrier_fs,
format_version, format_version,
anti_leak, anti_leak,
vault_id, vault_id,
@@ -432,7 +478,11 @@ impl SanctumFs {
} }
pub fn last_activity(&self) -> Arc<AtomicU64> { pub fn last_activity(&self) -> Arc<AtomicU64> {
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 { pub fn is_anti_leak_enabled(&self) -> bool {
@@ -440,11 +490,15 @@ impl SanctumFs {
} }
pub fn touch(&self) { pub fn touch(&self) {
let now = SystemTime::now() if let Some(ref cfs) = self.carrier_fs {
.duration_since(UNIX_EPOCH) cfs.touch();
.map(|d| d.as_secs()) } else {
.unwrap_or(0); let now = SystemTime::now()
self.last_activity.store(now, Ordering::Relaxed); .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 { fn path_to_str(path: &DavPath) -> String {
@@ -493,6 +547,10 @@ impl DavFileSystem for SanctumFs {
path: &'a DavPath, path: &'a DavPath,
options: OpenOptions, options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> { ) -> FsFuture<'a, Box<dyn DavFile>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.open(path, options);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
let (parent_path, file_name) = self.split_parent_and_name(&path_str); 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 { let node = match existing_node {
Some(n) => { 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) { if n.is_dir && (options.write || options.append) {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
@@ -579,8 +642,12 @@ impl DavFileSystem for SanctumFs {
fn read_dir<'a>( fn read_dir<'a>(
&'a self, &'a self,
path: &'a DavPath, path: &'a DavPath,
_meta: ReadDirMeta, meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> { ) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.read_dir(path, meta);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); 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<dyn DavMetaData>> { fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.metadata(path);
}
Box::pin(async move { Box::pin(async move {
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
if path_str != "/" && !path_str.is_empty() { 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<dyn DavMetaData>> { fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.symlink_metadata(path);
}
self.metadata(path) self.metadata(path)
} }
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { 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 { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); 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, ()> { 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 { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); 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, ()> { 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 { Box::pin(async move {
self.touch(); self.touch();
let path_str = Self::path_to_str(path); let path_str = Self::path_to_str(path);
@@ -704,6 +790,11 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden); 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 self.db
.delete_node(node.id) .delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
@@ -717,6 +808,10 @@ impl DavFileSystem for SanctumFs {
from: &'a DavPath, from: &'a DavPath,
to: &'a DavPath, to: &'a DavPath,
) -> FsFuture<'a, ()> { ) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
Box::pin(async move { Box::pin(async move {
self.touch(); self.touch();
let from_str = Self::path_to_str(from); let from_str = Self::path_to_str(from);
@@ -726,6 +821,11 @@ impl DavFileSystem for SanctumFs {
.resolve_path(&from_str)? .resolve_path(&from_str)?
.ok_or(FsError::NotFound)?; .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); let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) { if self.anti_leak && is_leak_file(to_name) {
+360
View File
@@ -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);
}