Files
sanctum/src/storage.rs
T
harald 541190cff4 feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- 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
2026-09-10 13:27:01 +02:00

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(&params_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");
}
}