feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability)
This commit is contained in:
+993
@@ -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(¤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<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
@@ -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>, [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<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.
|
||||
pub fn unwrap_dek(
|
||||
kek: &[u8; 32],
|
||||
@@ -109,31 +138,47 @@ pub fn unwrap_dek(
|
||||
nonce_bytes: &[u8; 12],
|
||||
tag_bytes: &[u8; 16],
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
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>, [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
|
||||
/// 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>, [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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod carrier;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod recovery;
|
||||
|
||||
+94
-26
@@ -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<char> {
|
||||
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() {
|
||||
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)?;
|
||||
|
||||
+18
-6
@@ -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))
|
||||
|
||||
+296
-8
@@ -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<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)]
|
||||
#[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<UnlockedKeys> {
|
||||
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<Option<(Zeroizing<[u8; 32]>, u32, u32)>> {
|
||||
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
||||
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<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).
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
|
||||
+108
-8
@@ -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<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,
|
||||
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<Zeroizing<[u8; 32]>>,
|
||||
carrier_node_id: Option<i64>,
|
||||
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<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 {
|
||||
@@ -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<dyn DavFile>> {
|
||||
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<Box<dyn DavDirEntry>>> {
|
||||
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<dyn DavMetaData>> {
|
||||
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<dyn DavMetaData>> {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user