- HF-01: eliminate visual leaks between decoy and hidden vaults during mount - HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history - HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions - HF-04: add --stealth mode for silent mounting in high-risk environments - VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
1713 lines
64 KiB
Rust
1713 lines
64 KiB
Rust
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{bail, Result};
|
|
use rand::rngs::OsRng;
|
|
use rand::RngCore;
|
|
use rusqlite::{params, Connection, OptionalExtension};
|
|
|
|
use crate::crypto::{
|
|
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;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone)]
|
|
pub struct NodeRecord {
|
|
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,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChunkRecord {
|
|
pub node_id: i64,
|
|
pub chunk_index: u32,
|
|
pub nonce: [u8; 12],
|
|
pub tag: [u8; 16],
|
|
pub ciphertext: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SlotMeta {
|
|
pub slot_id: u32,
|
|
pub version: u32,
|
|
pub kdf_salt: [u8; 16],
|
|
pub kdf_params: KdfParams,
|
|
pub wrapped_dek: Vec<u8>,
|
|
pub header_nonce: [u8; 12],
|
|
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 {
|
|
pub version: u32,
|
|
pub kdf_salt: [u8; 16],
|
|
pub kdf_params: KdfParams,
|
|
pub wrapped_dek: Vec<u8>,
|
|
pub header_nonce: [u8; 12],
|
|
pub header_tag: [u8; 16],
|
|
pub slots: Vec<SlotMeta>,
|
|
}
|
|
|
|
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<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_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
|
|
});
|
|
|
|
if let Some(payload) = res {
|
|
if matching.is_none() {
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
matching
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Database {
|
|
conn: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
fn current_timestamp() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
impl Database {
|
|
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
let path_ref = path.as_ref();
|
|
let conn = match Connection::open(path_ref) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let err_str = e.to_string();
|
|
if err_str.contains("Access is denied")
|
|
|| err_str.contains("permission denied")
|
|
|| err_str.contains("os error 5")
|
|
{
|
|
bail!(
|
|
"Zugriff auf '{}' verweigert (OS Fehler 5 / Access Denied).\n\
|
|
[!] Möglicherweise blockiert durch Windows Defender 'Überwachter Ordnerzugriff' (Controlled Folder Access).\n\
|
|
[i] Abhilfe: Fügen Sie 'sanctum.exe' in den Windows-Sicherheitseinstellungen (Viren- & Bedrohungsschutz -> Ransomware-Schutz -> Überwachter Ordnerzugriff) als erlaubte App hinzu, oder platzieren Sie den Container außerhalb geschützter Benutzerordner.",
|
|
path_ref.display()
|
|
);
|
|
}
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
let db = Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
};
|
|
db.init_pragmas()?;
|
|
db.ensure_schema_upgrades()?;
|
|
Ok(db)
|
|
}
|
|
|
|
/// Öffnet eine In-Memory-Datenbank (vorwiegend für Tests).
|
|
#[cfg(test)]
|
|
pub fn open_in_memory() -> Result<Self> {
|
|
let conn = Connection::open_in_memory()?;
|
|
let db = Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
};
|
|
db.init_pragmas()?;
|
|
db.ensure_schema_upgrades()?;
|
|
Ok(db)
|
|
}
|
|
|
|
pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, Connection> {
|
|
self.conn.lock().unwrap()
|
|
}
|
|
|
|
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
|
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
|
let meta = self.read_meta()?;
|
|
Ok(meta.authenticate(password))
|
|
}
|
|
|
|
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
|
|
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
|
if av != 2 {
|
|
// Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum
|
|
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
|
|
}
|
|
|
|
// Spalte slot_id in meta (falls aus v1 migriert)
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Setzt die vorgeschriebenen SQLite3-Pragmas: 8192 Page-Size, Incremental Auto-Vacuum, WAL, NORMAL synchronous, Secure Delete.
|
|
pub fn init_pragmas(&self) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute_batch(
|
|
"PRAGMA page_size = 8192;
|
|
PRAGMA auto_vacuum = INCREMENTAL;
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA secure_delete = ON;
|
|
PRAGMA busy_timeout = 5000;",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben.
|
|
pub fn incremental_vacuum(&self, pages: Option<usize>) -> Result<usize> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
|
if before <= 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
let pragma_sql = match pages {
|
|
Some(n) => format!("PRAGMA incremental_vacuum({});", n),
|
|
None => "PRAGMA incremental_vacuum;".to_string(),
|
|
};
|
|
|
|
let mut stmt = conn.prepare(&pragma_sql)?;
|
|
let mut rows = stmt.query([])?;
|
|
let mut stepped = 0;
|
|
while let Some(_) = rows.next()? {
|
|
stepped += 1;
|
|
}
|
|
drop(rows);
|
|
drop(stmt);
|
|
|
|
let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
|
let actual_freed = (before - after).max(0) as usize;
|
|
Ok(actual_freed.max(stepped))
|
|
}
|
|
|
|
/// Gibt die Anzahl ungenutzter Freelist-Seiten zurück.
|
|
pub fn freelist_count(&self) -> Result<usize> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let count: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0))?;
|
|
Ok(count as usize)
|
|
}
|
|
|
|
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
|
|
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
|
)?;
|
|
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
|
|
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
|
|
if node_id == ancestor_id {
|
|
return Ok(true);
|
|
}
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"WITH RECURSIVE sub(id) AS (
|
|
SELECT id FROM nodes WHERE id = ?1
|
|
UNION ALL
|
|
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
|
)
|
|
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
|
|
)?;
|
|
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
|
|
Ok(exists.is_some())
|
|
}
|
|
|
|
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
|
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1"
|
|
)?;
|
|
let chunks: Vec<(u32, usize)> = stmt
|
|
.query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
for (chunk_idx, ct_len) in chunks {
|
|
let mut noise = vec![0u8; ct_len];
|
|
let mut nonce_noise = [0u8; 12];
|
|
let mut tag_noise = [0u8; 16];
|
|
OsRng.fill_bytes(&mut noise);
|
|
OsRng.fill_bytes(&mut nonce_noise);
|
|
OsRng.fill_bytes(&mut tag_noise);
|
|
|
|
let _ = conn.execute(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5",
|
|
params![nonce_noise.as_slice(), tag_noise.as_slice(), noise, node_id, chunk_idx],
|
|
);
|
|
}
|
|
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);
|
|
|
|
conn.execute_batch("BEGIN TRANSACTION;")?;
|
|
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,
|
|
])?;
|
|
|
|
if b % 500 == 0 {
|
|
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
|
|
}
|
|
}
|
|
conn.execute_batch("COMMIT;")?;
|
|
|
|
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)
|
|
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
|
|
/// - Keinerlei Klartext-Kennzeichnungen wie `vault_id` in SQLite
|
|
pub fn init_schema_with_hidden(
|
|
&self,
|
|
salt: &[u8; 16],
|
|
kdf_params: &KdfParams,
|
|
wrapped_dek: &[u8],
|
|
header_nonce: &[u8; 12],
|
|
header_tag: &[u8; 16],
|
|
hidden: Option<(&[u8; 16], &KdfParams, &[u8], &[u8; 12], &[u8; 16])>,
|
|
) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS meta (
|
|
slot_id INTEGER NOT NULL PRIMARY KEY,
|
|
magic BLOB NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
kdf_salt BLOB NOT NULL,
|
|
kdf_params TEXT NOT NULL,
|
|
wrapped_dek BLOB NOT NULL,
|
|
header_nonce BLOB NOT NULL,
|
|
header_tag BLOB NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
parent_id INTEGER,
|
|
name TEXT NOT NULL,
|
|
is_dir INTEGER NOT NULL,
|
|
size INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
modified_at INTEGER NOT NULL,
|
|
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
node_id INTEGER NOT NULL,
|
|
chunk_index INTEGER NOT NULL,
|
|
nonce BLOB NOT NULL,
|
|
tag BLOB NOT NULL,
|
|
ciphertext BLOB NOT NULL,
|
|
PRIMARY KEY (node_id, chunk_index),
|
|
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
|
);",
|
|
)?;
|
|
|
|
// Slot 0 einfügen (Standard / Decoy Vault)
|
|
let params_json_0 = serde_json::to_string(kdf_params)?;
|
|
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.as_slice(),
|
|
params_json_0,
|
|
wrapped_dek,
|
|
header_nonce.as_slice(),
|
|
header_tag.as_slice(),
|
|
],
|
|
)?;
|
|
|
|
// Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
|
|
let now = current_timestamp();
|
|
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],
|
|
)?;
|
|
|
|
// Slot 1: Entweder echter Hidden Vault ODER ununterscheidbares kryptografisches Rauschen (Plausible Deniability)
|
|
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
|
|
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(),
|
|
],
|
|
)?;
|
|
} else {
|
|
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
|
|
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(),
|
|
],
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability).
|
|
pub fn init_schema(
|
|
&self,
|
|
salt: &[u8; 16],
|
|
kdf_params: &KdfParams,
|
|
wrapped_dek: &[u8],
|
|
header_nonce: &[u8; 12],
|
|
header_tag: &[u8; 16],
|
|
) -> Result<()> {
|
|
self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None)
|
|
}
|
|
|
|
/// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen).
|
|
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag
|
|
FROM meta ORDER BY slot_id ASC",
|
|
)?;
|
|
|
|
let rows = stmt.query_map([], |row| {
|
|
let slot_id: u32 = row.get(0)?;
|
|
let version: u32 = row.get(1)?;
|
|
let salt_vec: Vec<u8> = row.get(2)?;
|
|
let params_str: String = row.get(3)?;
|
|
let wrapped_dek: Vec<u8> = row.get(4)?;
|
|
let nonce_vec: Vec<u8> = row.get(5)?;
|
|
let tag_vec: Vec<u8> = row.get(6)?;
|
|
|
|
let mut kdf_salt = [0u8; 16];
|
|
if salt_vec.len() == 16 {
|
|
kdf_salt.copy_from_slice(&salt_vec);
|
|
}
|
|
let mut header_nonce = [0u8; 12];
|
|
if nonce_vec.len() == 12 {
|
|
header_nonce.copy_from_slice(&nonce_vec);
|
|
}
|
|
let mut header_tag = [0u8; 16];
|
|
if tag_vec.len() == 16 {
|
|
header_tag.copy_from_slice(&tag_vec);
|
|
}
|
|
|
|
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default();
|
|
|
|
Ok(SlotMeta {
|
|
slot_id,
|
|
version,
|
|
kdf_salt,
|
|
kdf_params,
|
|
wrapped_dek,
|
|
header_nonce,
|
|
header_tag,
|
|
})
|
|
})?;
|
|
|
|
let mut slots = Vec::new();
|
|
for r in rows {
|
|
slots.push(r?);
|
|
}
|
|
Ok(slots)
|
|
}
|
|
|
|
/// Liest die Metadaten des Containers (Slot 0 und alle Slots) aus der `meta`-Tabelle aus und verifiziert die Magic Bytes.
|
|
pub fn read_meta(&self) -> Result<ContainerMeta> {
|
|
let slots = self.read_slots()?;
|
|
if slots.is_empty() {
|
|
bail!("Container-Header ist leer oder beschädigt");
|
|
}
|
|
|
|
let slot0 = &slots[0];
|
|
let conn = self.conn.lock().unwrap();
|
|
let magic: Vec<u8> = conn.query_row(
|
|
"SELECT magic FROM meta WHERE slot_id = 0 LIMIT 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
|
|
if magic.as_slice() != MAGIC_BYTES.as_slice() {
|
|
bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein");
|
|
}
|
|
|
|
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
|
|
bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version);
|
|
}
|
|
|
|
Ok(ContainerMeta {
|
|
version: slot0.version,
|
|
kdf_salt: slot0.kdf_salt,
|
|
kdf_params: slot0.kdf_params.clone(),
|
|
wrapped_dek: slot0.wrapped_dek.clone(),
|
|
header_nonce: slot0.header_nonce,
|
|
header_tag: slot0.header_tag,
|
|
slots,
|
|
})
|
|
}
|
|
|
|
/// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK in Slot 0 (Passwortänderung).
|
|
pub fn update_meta_keys(
|
|
&self,
|
|
new_salt: &[u8; 16],
|
|
new_params: &KdfParams,
|
|
new_wrapped_dek: &[u8],
|
|
new_header_nonce: &[u8; 12],
|
|
new_header_tag: &[u8; 16],
|
|
) -> Result<()> {
|
|
self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag)
|
|
}
|
|
|
|
/// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots.
|
|
pub fn update_slot_keys(
|
|
&self,
|
|
slot_id: u32,
|
|
new_salt: &[u8; 16],
|
|
new_params: &KdfParams,
|
|
new_wrapped_dek: &[u8],
|
|
new_header_nonce: &[u8; 12],
|
|
new_header_tag: &[u8; 16],
|
|
) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let params_json = serde_json::to_string(new_params)?;
|
|
let rows_affected = conn.execute(
|
|
"UPDATE meta SET kdf_salt = ?1, kdf_params = ?2, wrapped_dek = ?3, header_nonce = ?4, header_tag = ?5 WHERE slot_id = ?6",
|
|
params![
|
|
new_salt.as_slice(),
|
|
params_json,
|
|
new_wrapped_dek,
|
|
new_header_nonce.as_slice(),
|
|
new_header_tag.as_slice(),
|
|
slot_id,
|
|
],
|
|
)?;
|
|
|
|
if rows_affected == 0 {
|
|
bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Aktualisiert die Version in der meta-Tabelle (z. B. für Migrationen oder Tests).
|
|
pub fn set_meta_version(&self, version: u32) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let rows_affected = conn.execute("UPDATE meta SET version = ?1", params![version])?;
|
|
if rows_affected == 0 {
|
|
bail!("Konnte Container-Version nicht aktualisieren: meta-Tabelle ist leer");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Ermittelt die Root-Knoten-ID für einen bestimmten Vault (Vault 0 = 1, Vault 1 = 2).
|
|
pub fn get_root_node_id_for_vault(vault_id: u32) -> i64 {
|
|
if vault_id == 1 {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
/// Löst einen hierarchischen Pfad innerhalb eines bestimmten Vaults auf.
|
|
pub fn resolve_path_in_vault(
|
|
&self,
|
|
raw_path: &str,
|
|
vault_id: u32,
|
|
dek: &[u8; 32],
|
|
) -> Result<Option<NodeRecord>> {
|
|
let root_id = Self::get_root_node_id_for_vault(vault_id);
|
|
let normalized = raw_path.trim_matches('/');
|
|
if normalized.is_empty() {
|
|
return self.get_node_by_id_in_vault(root_id, vault_id, dek);
|
|
}
|
|
|
|
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let mut current_id = root_id;
|
|
let mut last_record = None;
|
|
|
|
for (idx, segment) in segments.iter().enumerate() {
|
|
if vault_id == 0 {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
|
FROM nodes
|
|
WHERE parent_id = ?1 AND name = ?2",
|
|
)?;
|
|
|
|
let record: Option<NodeRecord> = stmt
|
|
.query_row(params![current_id, segment], |row| {
|
|
Ok(NodeRecord {
|
|
id: row.get(0)?,
|
|
parent_id: row.get(1)?,
|
|
name: row.get(2)?,
|
|
is_dir: row.get::<_, i32>(3)? != 0,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
created_at: row.get::<_, i64>(5)? as u64,
|
|
modified_at: row.get::<_, i64>(6)? as u64,
|
|
})
|
|
})
|
|
.optional()?;
|
|
|
|
match record {
|
|
Some(rec) => {
|
|
if idx + 1 < segments.len() && !rec.is_dir {
|
|
return Ok(None);
|
|
}
|
|
current_id = rec.id;
|
|
last_record = Some(rec);
|
|
}
|
|
None => return Ok(None),
|
|
}
|
|
} else {
|
|
// Hidden Vault: Durchsuche Kinder des aktuellen Ordners und entschlüssele die Namen
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
|
FROM nodes
|
|
WHERE parent_id = ?1",
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![current_id], |row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, Option<i64>>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, i32>(3)? != 0,
|
|
row.get::<_, i64>(4)? as u64,
|
|
row.get::<_, i64>(5)? as u64,
|
|
row.get::<_, i64>(6)? as u64,
|
|
))
|
|
})?;
|
|
|
|
let mut matched_record = None;
|
|
for r in rows {
|
|
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
|
let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
|
if dec_name == *segment {
|
|
matched_record = Some(NodeRecord {
|
|
id,
|
|
parent_id: p_id,
|
|
name: dec_name,
|
|
is_dir,
|
|
size,
|
|
created_at: c_at,
|
|
modified_at: m_at,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
match matched_record {
|
|
Some(rec) => {
|
|
if idx + 1 < segments.len() && !rec.is_dir {
|
|
return Ok(None);
|
|
}
|
|
current_id = rec.id;
|
|
last_record = Some(rec);
|
|
}
|
|
None => return Ok(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(last_record)
|
|
}
|
|
|
|
/// Löst einen hierarchischen Pfad im Standard-Vault (Vault 0) auf.
|
|
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
|
|
self.resolve_path_in_vault(raw_path, 0, &[0u8; 32])
|
|
}
|
|
|
|
pub fn get_node_by_id_in_vault(
|
|
&self,
|
|
id: i64,
|
|
vault_id: u32,
|
|
dek: &[u8; 32],
|
|
) -> Result<Option<NodeRecord>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
|
FROM nodes WHERE id = ?1",
|
|
)?;
|
|
|
|
let record = stmt
|
|
.query_row(params![id], |row| {
|
|
let enc_name: String = row.get(2)?;
|
|
let p_id: Option<i64> = row.get(1)?;
|
|
let name = if vault_id == 1 {
|
|
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
|
|
} else {
|
|
enc_name
|
|
};
|
|
|
|
Ok(NodeRecord {
|
|
id: row.get(0)?,
|
|
parent_id: row.get(1)?,
|
|
name,
|
|
is_dir: row.get::<_, i32>(3)? != 0,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
created_at: row.get::<_, i64>(5)? as u64,
|
|
modified_at: row.get::<_, i64>(6)? as u64,
|
|
})
|
|
})
|
|
.optional()?;
|
|
|
|
Ok(record)
|
|
}
|
|
|
|
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
|
|
self.get_node_by_id_in_vault(id, 0, &[0u8; 32])
|
|
}
|
|
|
|
/// Listet alle direkten Kinder eines Verzeichnisknotens innerhalb eines Vaults auf.
|
|
pub fn list_children_in_vault(
|
|
&self,
|
|
parent_id: i64,
|
|
vault_id: u32,
|
|
dek: &[u8; 32],
|
|
) -> Result<Vec<NodeRecord>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
|
FROM nodes
|
|
WHERE parent_id = ?1
|
|
ORDER BY is_dir DESC, id ASC",
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![parent_id], |row| {
|
|
let enc_name: String = row.get(2)?;
|
|
let name = if vault_id == 1 {
|
|
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
|
|
} else {
|
|
enc_name
|
|
};
|
|
|
|
Ok(NodeRecord {
|
|
id: row.get(0)?,
|
|
parent_id: row.get(1)?,
|
|
name,
|
|
is_dir: row.get::<_, i32>(3)? != 0,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
created_at: row.get::<_, i64>(5)? as u64,
|
|
modified_at: row.get::<_, i64>(6)? as u64,
|
|
})
|
|
})?;
|
|
|
|
let mut entries = Vec::new();
|
|
for r in rows {
|
|
entries.push(r?);
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Listet alle direkten Kinder eines Verzeichnisknotens im Standard-Vault auf.
|
|
pub fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>> {
|
|
self.list_children_in_vault(parent_id, 0, &[0u8; 32])
|
|
}
|
|
|
|
/// Erstellt einen neuen Datei- oder Ordnerknoten in einem bestimmten Vault.
|
|
pub fn create_node_in_vault(
|
|
&self,
|
|
vault_id: u32,
|
|
parent_id: i64,
|
|
name: &str,
|
|
is_dir: bool,
|
|
dek: &[u8; 32],
|
|
) -> Result<NodeRecord> {
|
|
let now = current_timestamp();
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let stored_name = if vault_id == 1 {
|
|
encrypt_node_name(dek, parent_id, name)
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
conn.execute(
|
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
|
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
|
|
params![parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
|
|
)?;
|
|
|
|
let new_id = conn.last_insert_rowid();
|
|
|
|
let _ = conn.execute(
|
|
"UPDATE nodes SET modified_at = ?1 WHERE id = ?2",
|
|
params![now, parent_id],
|
|
);
|
|
|
|
Ok(NodeRecord {
|
|
id: new_id,
|
|
parent_id: Some(parent_id),
|
|
name: name.to_string(),
|
|
is_dir,
|
|
size: 0,
|
|
created_at: now,
|
|
modified_at: now,
|
|
})
|
|
}
|
|
|
|
/// Erstellt einen neuen Datei- oder Ordnerknoten im Standard-Vault.
|
|
pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord> {
|
|
self.create_node_in_vault(0, parent_id, name, is_dir, &[0u8; 32])
|
|
}
|
|
|
|
/// Aktualisiert Dateigröße und Modifikationszeitstempel eines Knotens.
|
|
pub fn update_node_size_and_time(&self, id: i64, size: u64, modified_at: u64) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute(
|
|
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
|
|
params![size as i64, modified_at as i64, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
|
pub fn delete_node(&self, id: i64) -> Result<()> {
|
|
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
|
let _ = self.shred_chunks_for_node(id);
|
|
|
|
// 2. Shredde auch rekursiv alle Unterknoten
|
|
let child_ids: Vec<i64> = {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
|
let ids = stmt
|
|
.query_map(params![id], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
ids
|
|
};
|
|
|
|
for child_id in child_ids {
|
|
let _ = self.delete_node(child_id);
|
|
}
|
|
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
|
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Benennt einen Knoten um und/oder verschiebt ihn in ein anderes Verzeichnis.
|
|
pub fn rename_node_in_vault(
|
|
&self,
|
|
id: i64,
|
|
new_parent_id: i64,
|
|
new_name: &str,
|
|
vault_id: u32,
|
|
dek: &[u8; 32],
|
|
) -> Result<()> {
|
|
let now = current_timestamp();
|
|
let conn = self.conn.lock().unwrap();
|
|
let stored_name = if vault_id == 1 {
|
|
encrypt_node_name(dek, new_parent_id, new_name)
|
|
} else {
|
|
new_name.to_string()
|
|
};
|
|
|
|
conn.execute(
|
|
"UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4",
|
|
params![new_parent_id, stored_name, now, id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> {
|
|
self.rename_node_in_vault(id, new_parent_id, new_name, 0, &[0u8; 32])
|
|
}
|
|
|
|
/// Liest einen verschlüsselten Chunk aus der Datenbank.
|
|
pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result<Option<ChunkRecord>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT nonce, tag, ciphertext FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
|
|
)?;
|
|
|
|
let record = stmt
|
|
.query_row(params![node_id, chunk_index], |row| {
|
|
let nonce_vec: Vec<u8> = row.get(0)?;
|
|
let tag_vec: Vec<u8> = row.get(1)?;
|
|
let ciphertext: Vec<u8> = row.get(2)?;
|
|
|
|
let mut nonce = [0u8; 12];
|
|
let mut tag = [0u8; 16];
|
|
if nonce_vec.len() == 12 {
|
|
nonce.copy_from_slice(&nonce_vec);
|
|
}
|
|
if tag_vec.len() == 16 {
|
|
tag.copy_from_slice(&tag_vec);
|
|
}
|
|
|
|
Ok(ChunkRecord {
|
|
node_id,
|
|
chunk_index,
|
|
nonce,
|
|
tag,
|
|
ciphertext,
|
|
})
|
|
})
|
|
.optional()?;
|
|
|
|
Ok(record)
|
|
}
|
|
|
|
/// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
|
|
pub fn write_chunk(
|
|
&self,
|
|
node_id: i64,
|
|
chunk_index: u32,
|
|
nonce: &[u8; 12],
|
|
tag: &[u8; 16],
|
|
ciphertext: &[u8],
|
|
) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
|
|
nonce = excluded.nonce,
|
|
tag = excluded.tag,
|
|
ciphertext = excluded.ciphertext",
|
|
params![
|
|
node_id,
|
|
chunk_index,
|
|
nonce.as_slice(),
|
|
tag.as_slice(),
|
|
ciphertext,
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
|
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
|
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
|
|
let mut conn = self.conn.lock().unwrap();
|
|
let tx = conn.transaction()?;
|
|
{
|
|
let mut stmt = tx.prepare(
|
|
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1 AND chunk_index > ?2",
|
|
)?;
|
|
let chunks_to_shred: Vec<(u32, usize)> = stmt
|
|
.query_map(params![node_id, max_chunk_index], |row| {
|
|
Ok((row.get(0)?, row.get::<_, usize>(1)?))
|
|
})?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
let mut update_stmt = tx.prepare(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5",
|
|
)?;
|
|
|
|
for (idx, len) in chunks_to_shred {
|
|
let mut dummy_nonce = [0u8; 12];
|
|
let mut dummy_tag = [0u8; 16];
|
|
let mut dummy_payload = vec![0u8; len];
|
|
OsRng.fill_bytes(&mut dummy_nonce);
|
|
OsRng.fill_bytes(&mut dummy_tag);
|
|
OsRng.fill_bytes(&mut dummy_payload);
|
|
|
|
let _ = update_stmt.execute(params![
|
|
dummy_nonce.as_slice(),
|
|
dummy_tag.as_slice(),
|
|
dummy_payload.as_slice(),
|
|
node_id,
|
|
idx
|
|
]);
|
|
}
|
|
}
|
|
|
|
tx.execute(
|
|
"DELETE FROM chunks WHERE node_id = ?1 AND chunk_index > ?2",
|
|
params![node_id, max_chunk_index],
|
|
)?;
|
|
tx.commit()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
|
pub fn checkpoint(&self) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let _res: (i64, i64, i64) = conn.query_row(
|
|
"PRAGMA wal_checkpoint(TRUNCATE);",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Erstellt ein konsistentes Online-Live-Backup der gesamten Container-Datenbank via SQLite Online Backup API.
|
|
/// Kann auch während eines aktiven WebDAV-Mounts ohne Lese-/Schreibkonflikte ausgeführt werden.
|
|
pub fn online_backup<P: AsRef<Path>>(&self, dest_path: P) -> Result<()> {
|
|
let dest_path = dest_path.as_ref();
|
|
if let Some(parent) = dest_path.parent() {
|
|
if !parent.as_os_str().is_empty() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
}
|
|
|
|
let mut dest_conn = Connection::open(dest_path)?;
|
|
let src_conn = self.conn.lock().unwrap();
|
|
|
|
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
|
|
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
|
|
drop(backup);
|
|
|
|
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Stellt einen Container vollständig aus einer Sicherungskopie wieder her und verifiziert die Konsistenz.
|
|
pub fn restore_from_backup<P: AsRef<Path>>(backup_path: P, dest_path: P) -> Result<()> {
|
|
let backup_path = backup_path.as_ref();
|
|
let dest_path = dest_path.as_ref();
|
|
|
|
if !backup_path.exists() {
|
|
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
|
}
|
|
|
|
if let Some(parent) = dest_path.parent() {
|
|
if !parent.as_os_str().is_empty() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
}
|
|
|
|
let src_conn = Connection::open(backup_path)?;
|
|
let mut dest_conn = Connection::open(dest_path)?;
|
|
|
|
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
|
|
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
|
|
drop(backup);
|
|
drop(src_conn);
|
|
|
|
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
|
|
|
// B-Tree Integritätsprüfung
|
|
let check: String = dest_conn.query_row("PRAGMA quick_check;", [], |r| r.get(0))?;
|
|
if check != "ok" {
|
|
bail!("Integritätsprüfung des wiederhergestellten Containers fehlgeschlagen: {check}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung).
|
|
pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS meta (
|
|
slot_id INTEGER PRIMARY KEY DEFAULT 0,
|
|
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
|
|
);",
|
|
)?;
|
|
|
|
conn.execute("DELETE FROM meta", [])?;
|
|
|
|
let mut has_slot1 = false;
|
|
if !meta.slots.is_empty() {
|
|
for slot in &meta.slots {
|
|
if slot.slot_id == 1 {
|
|
has_slot1 = true;
|
|
}
|
|
let params_json = serde_json::to_string(&slot.kdf_params)?;
|
|
conn.execute(
|
|
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![
|
|
slot.slot_id,
|
|
MAGIC_BYTES.as_slice(),
|
|
slot.version,
|
|
slot.kdf_salt.as_slice(),
|
|
params_json,
|
|
slot.wrapped_dek,
|
|
slot.header_nonce.as_slice(),
|
|
slot.header_tag.as_slice(),
|
|
],
|
|
)?;
|
|
}
|
|
} else {
|
|
let params_json = serde_json::to_string(&meta.kdf_params)?;
|
|
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(),
|
|
meta.version,
|
|
meta.kdf_salt.as_slice(),
|
|
params_json,
|
|
meta.wrapped_dek,
|
|
meta.header_nonce.as_slice(),
|
|
meta.header_tag.as_slice(),
|
|
],
|
|
)?;
|
|
}
|
|
|
|
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
|
|
if !has_slot1 {
|
|
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(),
|
|
],
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus.
|
|
pub fn run_sqlite_integrity_check(&self) -> Result<Vec<String>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut issues = Vec::new();
|
|
|
|
// 1. PRAGMA integrity_check
|
|
let mut stmt = conn.prepare("PRAGMA integrity_check;")?;
|
|
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
|
for r in rows {
|
|
let msg = r?;
|
|
if msg.to_lowercase() != "ok" {
|
|
issues.push(format!("SQLite integrity error: {msg}"));
|
|
}
|
|
}
|
|
|
|
// 2. PRAGMA foreign_key_check
|
|
let mut fk_stmt = conn.prepare("PRAGMA foreign_key_check;")?;
|
|
let fk_rows = fk_stmt.query_map([], |row| {
|
|
let table: String = row.get(0)?;
|
|
let rowid: i64 = row.get(1)?;
|
|
let parent: String = row.get(2)?;
|
|
let fkid: i64 = row.get(3)?;
|
|
Ok(format!(
|
|
"Foreign Key Verletzung in Tabelle '{table}', RowId {rowid}, Ziel '{parent}', FK #{fkid}"
|
|
))
|
|
})?;
|
|
for r in fk_rows {
|
|
issues.push(r?);
|
|
}
|
|
|
|
Ok(issues)
|
|
}
|
|
|
|
/// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container.
|
|
pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let dirs: i64 =
|
|
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
|
|
r.get(0)
|
|
})?;
|
|
let files: i64 =
|
|
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| {
|
|
r.get(0)
|
|
})?;
|
|
let chunks: i64 = conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
|
|
Ok((dirs as usize, files as usize, chunks as usize))
|
|
}
|
|
|
|
/// Listet alle Knoten (Dateien und Ordner) im gesamten Baum auf.
|
|
pub fn list_all_nodes(&self) -> Result<Vec<NodeRecord>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes ORDER BY id ASC",
|
|
)?;
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(NodeRecord {
|
|
id: row.get(0)?,
|
|
parent_id: row.get(1)?,
|
|
name: row.get(2)?,
|
|
is_dir: row.get::<_, i32>(3)? != 0,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
created_at: row.get::<_, i64>(5)? as u64,
|
|
modified_at: row.get::<_, i64>(6)? as u64,
|
|
})
|
|
})?;
|
|
let mut result = Vec::new();
|
|
for r in rows {
|
|
result.push(r?);
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Liefert alle vorhandenen Chunk-Identifikatoren (node_id, chunk_index).
|
|
pub fn list_all_chunk_headers(&self) -> Result<Vec<(i64, u32)>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt =
|
|
conn.prepare("SELECT node_id, chunk_index FROM chunks ORDER BY node_id, chunk_index")?;
|
|
let rows = stmt.query_map([], |row| {
|
|
let nid: i64 = row.get(0)?;
|
|
let cidx: u32 = row.get(1)?;
|
|
Ok((nid, cidx))
|
|
})?;
|
|
let mut result = Vec::new();
|
|
for r in rows {
|
|
result.push(r?);
|
|
}
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_storage_schema_and_path_resolution() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let salt = [1u8; 16];
|
|
let kdf_params = KdfParams::default();
|
|
let wrapped_dek = vec![2u8; 32];
|
|
let nonce = [3u8; 12];
|
|
let tag = [4u8; 16];
|
|
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
|
|
|
// Meta abrufen
|
|
let meta = db.read_meta().unwrap();
|
|
assert_eq!(meta.version, FORMAT_VERSION);
|
|
assert_eq!(meta.kdf_salt, salt);
|
|
assert_eq!(meta.wrapped_dek, wrapped_dek);
|
|
|
|
// Root prüfen
|
|
let root = db.resolve_path("/").unwrap().expect("Root node must exist");
|
|
assert_eq!(root.id, 1);
|
|
assert!(root.is_dir);
|
|
|
|
// Ordner und Datei erstellen
|
|
let docs = db.create_node(root.id, "documents", true).unwrap();
|
|
assert_eq!(docs.name, "documents");
|
|
assert!(docs.is_dir);
|
|
|
|
let file = db.create_node(docs.id, "notes.txt", false).unwrap();
|
|
assert_eq!(file.name, "notes.txt");
|
|
assert!(!file.is_dir);
|
|
|
|
// Pfadauflösung testen
|
|
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
|
|
assert_eq!(resolved_file.id, file.id);
|
|
|
|
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
|
|
assert_eq!(resolved_docs.id, docs.id);
|
|
|
|
// Chunks schreiben & lesen
|
|
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
|
|
let c_nonce = [7u8; 12];
|
|
let c_tag = [8u8; 16];
|
|
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
|
|
|
|
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
|
|
assert_eq!(chunk.ciphertext, test_cipher);
|
|
|
|
// Truncate
|
|
db.truncate_chunks_after(file.id, 0).unwrap();
|
|
let chunk_after = db.read_chunk(file.id, 0).unwrap();
|
|
assert!(chunk_after.is_some());
|
|
|
|
// Löschen
|
|
db.delete_node(file.id).unwrap();
|
|
let deleted_res = db.resolve_path("/documents/notes.txt").unwrap();
|
|
assert!(deleted_res.is_none());
|
|
assert!(db.read_chunk(file.id, 0).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_storage_compaction_and_incremental_vacuum() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path = temp_dir.join(format!("compact_test_{}.sanctum", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
let db = Database::open(&db_path).unwrap();
|
|
|
|
let salt = [1u8; 16];
|
|
let kdf_params = KdfParams::default();
|
|
let wrapped_dek = vec![2u8; 32];
|
|
let nonce = [3u8; 12];
|
|
let tag = [4u8; 16];
|
|
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
|
|
|
let root = db.resolve_path("/").unwrap().expect("Root node");
|
|
let file = db.create_node(root.id, "large_file.bin", false).unwrap();
|
|
|
|
// 20 Chunks à 64 KB schreiben, um SQLite Seiten zuzuweisen
|
|
let payload = vec![0x42u8; 64 * 1024];
|
|
let c_nonce = [5u8; 12];
|
|
let c_tag = [6u8; 16];
|
|
for i in 0..20 {
|
|
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap();
|
|
}
|
|
db.checkpoint().unwrap();
|
|
|
|
// Datei löschen -> Chunks werden geschreddert und Seiten wandern in Freelist
|
|
db.delete_node(file.id).unwrap();
|
|
db.checkpoint().unwrap();
|
|
|
|
let freelist_before = db.freelist_count().unwrap();
|
|
assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten");
|
|
|
|
// Incremental Vacuum ausführen
|
|
let freed = db.incremental_vacuum(None).unwrap();
|
|
assert!(freed > 0, "Es sollten Seiten freigegeben werden");
|
|
assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden");
|
|
|
|
let freelist_after = db.freelist_count().unwrap();
|
|
assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein");
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cryptographic_chunk_shredding() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let salt = [1u8; 16];
|
|
let kdf_params = KdfParams::default();
|
|
let wrapped_dek = vec![2u8; 32];
|
|
let nonce = [3u8; 12];
|
|
let tag = [4u8; 16];
|
|
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
|
let root = db.resolve_path("/").unwrap().expect("Root");
|
|
let file = db.create_node(root.id, "sensitive.dat", false).unwrap();
|
|
|
|
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
|
|
let c_nonce = [10u8; 12];
|
|
let c_tag = [11u8; 16];
|
|
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap();
|
|
|
|
// Shredde Chunks
|
|
db.shred_chunks_for_node(file.id).unwrap();
|
|
|
|
// Prüfe, was sich in der Chunks-Tabelle befindet
|
|
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch");
|
|
assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!");
|
|
assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein");
|
|
assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein");
|
|
assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein");
|
|
}
|
|
|
|
#[test]
|
|
fn test_hidden_vault_isolation_and_filename_encryption() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let salt0 = [1u8; 16];
|
|
let kdf_params0 = KdfParams::default();
|
|
let wrapped_dek0 = vec![10u8; 32];
|
|
let nonce0 = [11u8; 12];
|
|
let tag0 = [12u8; 16];
|
|
|
|
let salt1 = [2u8; 16];
|
|
let kdf_params1 = KdfParams::default();
|
|
let wrapped_dek1 = vec![20u8; 32];
|
|
let nonce1 = [21u8; 12];
|
|
let tag1 = [22u8; 16];
|
|
|
|
let dek0 = [0xAAu8; 32];
|
|
let dek1 = [0xBBu8; 32];
|
|
|
|
db.init_schema_with_hidden(
|
|
&salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0,
|
|
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
|
).unwrap();
|
|
|
|
// Slots prüfen
|
|
let slots = db.read_slots().unwrap();
|
|
assert_eq!(slots.len(), 2);
|
|
assert_eq!(slots[0].slot_id, 0);
|
|
assert_eq!(slots[1].slot_id, 1);
|
|
|
|
// Datei in Vault 0 (Decoy) erstellen
|
|
let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0");
|
|
assert_eq!(root0.id, 1);
|
|
let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap();
|
|
|
|
// Datei in Vault 1 (Hidden) erstellen
|
|
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1");
|
|
assert_eq!(root1.id, 2);
|
|
let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap();
|
|
|
|
// Auflösen in Vault 0: Sieht nur public_recipe.txt
|
|
let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap();
|
|
assert!(res_decoy.is_some());
|
|
assert_eq!(res_decoy.unwrap().id, decoy_file.id);
|
|
|
|
let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap();
|
|
assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!");
|
|
|
|
// Auflösen in Vault 1: Sieht nur classified_leak.pdf
|
|
let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap();
|
|
assert!(res_hidden.is_some());
|
|
assert_eq!(res_hidden.unwrap().id, hidden_file.id);
|
|
|
|
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
|
|
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
|
|
|
|
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
|
|
let conn = db.conn.lock().unwrap();
|
|
let raw_name_v0: String = conn.query_row(
|
|
"SELECT name FROM nodes WHERE id = ?1",
|
|
params![decoy_file.id],
|
|
|r| r.get(0),
|
|
).unwrap();
|
|
assert_eq!(raw_name_v0, "public_recipe.txt");
|
|
|
|
let raw_name_v1: String = conn.query_row(
|
|
"SELECT name FROM nodes WHERE id = ?1",
|
|
params![hidden_file.id],
|
|
|r| r.get(0),
|
|
).unwrap();
|
|
// Plausible Deniability: Kein $h$-Präfix, kein Klartext
|
|
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
|
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
|
|
|
// Keine Spalte `vault_id` in nodes oder chunks
|
|
let has_vault_id_nodes: i64 = conn.query_row(
|
|
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
|
[],
|
|
|r| r.get(0),
|
|
).unwrap();
|
|
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
|
|
|
|
let has_vault_id_chunks: i64 = conn.query_row(
|
|
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
|
[],
|
|
|r| r.get(0),
|
|
).unwrap();
|
|
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
|
}
|
|
}
|