- R-NEW-1: Recalculate metadata HMAC upon recovery-key restore and mark backup restores as PendingRebuild to rebuild transparently on first mount - CI-01: Update release.yaml to compile both Windows x86_64 and Linux musl with pinned Zig 0.16.0 and cargo-zigbuild 0.23.4 - W-1: Implement statvfs quota determination on Unix via libc - Add RELEASE_PROCESS.md documenting release architecture and steps - Bump version to 0.9.2 across manifests, lockfile, docs, Scoop and WinGet
3411 lines
125 KiB
Rust
3411 lines
125 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 tracing::{error, warn};
|
|
|
|
use crate::crypto::{
|
|
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
|
|
encrypt_node_name, generate_dummy_slot, unwrap_key_payload, validate_kdf_params,
|
|
verify_metadata_mac, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
|
FORMAT_VERSION_V2, FORMAT_VERSION_V3, 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 generation: u64,
|
|
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)
|
|
);
|
|
|
|
/// RAII-Guard für den Advisory-Lock eines gemounteten Containers (S-06).
|
|
pub struct AdvisoryLockGuard {
|
|
db: Database,
|
|
}
|
|
|
|
impl Drop for AdvisoryLockGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.db.release_advisory_lock();
|
|
}
|
|
}
|
|
|
|
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> {
|
|
// SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek)
|
|
// Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten
|
|
if self.slots.is_empty() || self.slots.len() > 2 {
|
|
return None;
|
|
}
|
|
if !self.slots.iter().any(|s| s.slot_id == 0) {
|
|
return None;
|
|
}
|
|
if self.slots.iter().any(|s| s.slot_id > 1) {
|
|
return None;
|
|
}
|
|
let mut seen_ids = std::collections::HashSet::new();
|
|
for slot in &self.slots {
|
|
if !seen_ids.insert(slot.slot_id) {
|
|
return None;
|
|
}
|
|
if validate_kdf_params(&slot.kdf_params).is_err() {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
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>>,
|
|
active_session: Arc<Mutex<Option<(u32, Zeroizing<[u8; 32]>)>>>,
|
|
}
|
|
|
|
fn current_timestamp() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum MetadataMacStatus {
|
|
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten überein.
|
|
Valid,
|
|
/// Container-Header wurde frisch aus einem Backup restauriert (metadata_gen == 0 && metadata_mac IS NULL).
|
|
/// Der MAC muss beim Mounten transparent mit dem aktiven DEK neu aufgebaut werden.
|
|
PendingRebuild,
|
|
/// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
|
|
Invalid,
|
|
}
|
|
|
|
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());
|
|
}
|
|
};
|
|
|
|
// S-04: Untrusted Container & Fremddatei-Schutz.
|
|
// Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden:
|
|
// Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen.
|
|
let table_count: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(0);
|
|
|
|
if table_count > 0 {
|
|
let has_sanctum_schema: bool = {
|
|
let has_meta: bool = conn
|
|
.query_row(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
|
[],
|
|
|_| Ok(()),
|
|
)
|
|
.optional()?
|
|
.is_some();
|
|
|
|
if !has_meta {
|
|
false
|
|
} else {
|
|
let magic_opt: Option<Option<Vec<u8>>> = conn
|
|
.query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0))
|
|
.optional()?;
|
|
match magic_opt {
|
|
Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(),
|
|
Some(None) => false,
|
|
None => {
|
|
// Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung).
|
|
// Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist,
|
|
// um Fremddatenbanken weiterhin strikt abzuweisen.
|
|
let has_nodes: bool = conn
|
|
.query_row(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;",
|
|
[],
|
|
|_| Ok(()),
|
|
)
|
|
.optional()?
|
|
.is_some();
|
|
let has_chunks: bool = conn
|
|
.query_row(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;",
|
|
[],
|
|
|_| Ok(()),
|
|
)
|
|
.optional()?
|
|
.is_some();
|
|
has_nodes && has_chunks
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if !has_sanctum_schema {
|
|
bail!(
|
|
"Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.",
|
|
path_ref.display()
|
|
);
|
|
}
|
|
|
|
// SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS)
|
|
let has_meta_table: bool = conn
|
|
.query_row(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
|
[],
|
|
|_| Ok(()),
|
|
)
|
|
.optional()?
|
|
.is_some();
|
|
if has_meta_table {
|
|
let slot_count: i64 = conn
|
|
.query_row("SELECT count(*) FROM meta;", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
if slot_count > 2 {
|
|
bail!(
|
|
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
|
slot_count
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let db = Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
active_session: Arc::new(Mutex::new(None)),
|
|
};
|
|
db.init_pragmas()?;
|
|
if table_count > 0 {
|
|
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)),
|
|
active_session: Arc::new(Mutex::new(None)),
|
|
};
|
|
db.init_pragmas()?;
|
|
db.ensure_schema_upgrades()?;
|
|
Ok(db)
|
|
}
|
|
|
|
/// Erwirbt den Mutex auf die SQLite-Verbindung und fängt eventuelles Mutex-Poisoning ab (Z-02).
|
|
pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
|
self.conn.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, Connection> {
|
|
self.conn()
|
|
}
|
|
|
|
/// Ermittelt den physischen Dateipfad des geöffneten Containers aus SQLite (sofern nicht in-memory).
|
|
pub fn container_path(&self) -> Option<std::path::PathBuf> {
|
|
let conn = self.conn();
|
|
let path_str: String = conn
|
|
.query_row("PRAGMA database_list;", [], |r| r.get(2))
|
|
.ok()?;
|
|
if path_str.is_empty() {
|
|
None
|
|
} else {
|
|
Some(std::path::PathBuf::from(path_str))
|
|
}
|
|
}
|
|
|
|
/// Ermittelt die physische Dateigröße des Containers über die SQLite-Page-Statistik (Z-04).
|
|
pub fn get_container_file_size(&self) -> Result<u64> {
|
|
let conn = self.conn();
|
|
let page_count: i64 = conn.query_row("PRAGMA page_count;", [], |r| r.get(0))?;
|
|
let page_size: i64 = conn.query_row("PRAGMA page_size;", [], |r| r.get(0))?;
|
|
Ok((page_count.saturating_mul(page_size)).max(0) as u64)
|
|
}
|
|
|
|
/// Ermittelt die kumulierte Dateigröße aller im Container gespeicherten Nutzdateien (Z-04).
|
|
pub fn get_total_used_size(&self) -> Result<u64> {
|
|
let conn = self.conn();
|
|
let total: i64 = conn.query_row(
|
|
"SELECT COALESCE(SUM(size), 0) FROM nodes WHERE is_dir = 0",
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
Ok(total.max(0) as u64)
|
|
}
|
|
|
|
/// Erzeugt eine geklonte Instanz mit einer isolierten aktiven Session (Slot & DEK).
|
|
pub fn with_session(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) -> Self {
|
|
Self {
|
|
conn: self.conn.clone(),
|
|
active_session: Arc::new(Mutex::new(Some((slot_id, dek)))),
|
|
}
|
|
}
|
|
|
|
/// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01) auf Slot 0.
|
|
pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) {
|
|
self.set_active_slot_and_dek(0, dek);
|
|
}
|
|
|
|
/// Setzt den aktiven Slot und DEK für automatische Metadaten-Authentifizierung (K-01).
|
|
pub fn set_active_slot_and_dek(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) {
|
|
*self
|
|
.active_session
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner()) = Some((slot_id, dek));
|
|
}
|
|
|
|
/// Gibt den aktuellen aktiven DEK zurück, falls gesetzt.
|
|
pub fn active_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
|
|
self.active_session
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.as_ref()
|
|
.map(|(_, dek)| dek.clone())
|
|
}
|
|
|
|
/// 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()?;
|
|
let res = meta.authenticate(password);
|
|
if let Some(ref keys) = res {
|
|
self.set_active_slot_and_dek(keys.slot_id(), keys.dek().clone());
|
|
if let Some(cid) = keys.carrier_node_id() {
|
|
let _ = self.mark_carrier_node_id(cid);
|
|
}
|
|
}
|
|
Ok(res)
|
|
}
|
|
|
|
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch.
|
|
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
|
let conn = self.conn();
|
|
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",
|
|
[],
|
|
);
|
|
|
|
// Spalte is_carrier in nodes (S-03 Carrier-Schutz)
|
|
let _ = conn.execute(
|
|
"ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
|
|
// Spalte metadata_mac in meta (Format V3 / K-01)
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []);
|
|
|
|
// Spalte metadata_gen in meta (Format V3 / K-01)
|
|
let _ = conn.execute(
|
|
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
|
|
// Spalte generation in chunks (Format V3 / K-02)
|
|
let _ = conn.execute(
|
|
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
|
|
// Spalten für Advisory-Lock (S-06)
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_pid INTEGER", []);
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_host TEXT", []);
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_time INTEGER", []);
|
|
|
|
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();
|
|
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();
|
|
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();
|
|
let count: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0))?;
|
|
Ok(count as usize)
|
|
}
|
|
|
|
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
|
|
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
|
let conn = self.conn();
|
|
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?;
|
|
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Markiert einen existierenden Knoten explizit als Carrier.
|
|
pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> {
|
|
let conn = self.conn();
|
|
conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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();
|
|
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).
|
|
///
|
|
/// SA-07 / Technischer Hinweis zu sicherem Löschen:
|
|
/// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records
|
|
/// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor
|
|
/// logischer Wiederherstellung auf Dateisystem- und Datenbankebene.
|
|
/// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS)
|
|
/// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization)
|
|
/// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren
|
|
/// neuen Flash-Speicherzellen zuweisen.
|
|
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
|
let conn = self.conn();
|
|
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();
|
|
|
|
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,
|
|
metadata_mac BLOB,
|
|
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
|
lock_pid INTEGER,
|
|
lock_host TEXT,
|
|
lock_time INTEGER
|
|
);
|
|
|
|
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,
|
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
|
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,
|
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
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 (is_carrier = 1)
|
|
conn.execute(
|
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
|
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
|
|
params![c_name, c_size as i64, now, now],
|
|
)?;
|
|
let c_id = conn.last_insert_rowid();
|
|
|
|
// Berechne Blockanzahl (min. 3 Blöcke: Block 0/1 für Superblock, Block 2 für Inode-Seite)
|
|
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(3) 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 Carrier-Format V2 (Superblock auf Block 0/1, Inode-Seite auf Block 2)
|
|
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
|
|
|
|
// 1. Block 2 schreiben: Erste Inode-Tabellenseite mit Wurzelverzeichnis '/'
|
|
let root_page = crate::carrier::CarrierInodePage {
|
|
magic: *crate::carrier::CARRIER_PAGE_MAGIC,
|
|
entries: manifest.inodes.values().cloned().collect(),
|
|
};
|
|
let root_page_bytes = serde_json::to_vec(&root_page)?;
|
|
|
|
let (inner_ct_2, inner_nonce_2, inner_tag_2) =
|
|
crate::crypto::encrypt_chunk(dek_1, c_id, 2, &root_page_bytes, FORMAT_VERSION, 0)?;
|
|
let inner_ct_len_2 = inner_ct_2.len() as u32;
|
|
|
|
let mut outer_plaintext_2 = vec![0u8; CHUNK_SIZE];
|
|
OsRng.fill_bytes(&mut outer_plaintext_2);
|
|
|
|
outer_plaintext_2[0..12].copy_from_slice(&inner_nonce_2);
|
|
outer_plaintext_2[12..28].copy_from_slice(&inner_tag_2);
|
|
outer_plaintext_2[28..32].copy_from_slice(&inner_ct_len_2.to_le_bytes());
|
|
let ct_end_2 = 32 + inner_ct_2.len();
|
|
if ct_end_2 > CHUNK_SIZE {
|
|
bail!("Inode-Page-Payload zu groß für Block 2");
|
|
}
|
|
outer_plaintext_2[32..ct_end_2].copy_from_slice(&inner_ct_2);
|
|
|
|
let (outer_ct_2, outer_nonce_2, outer_tag_2) = crate::crypto::encrypt_chunk(
|
|
dek_0,
|
|
c_id,
|
|
2,
|
|
&outer_plaintext_2,
|
|
FORMAT_VERSION,
|
|
0,
|
|
)?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, 2, 0, ?2, ?3, ?4)",
|
|
params![
|
|
c_id,
|
|
outer_nonce_2.as_slice(),
|
|
outer_tag_2.as_slice(),
|
|
outer_ct_2
|
|
],
|
|
)?;
|
|
|
|
// 2. Superblock (Block 0 & 1, redundante Zweitkopie C-02, Format V2)
|
|
let superblock = crate::carrier::CarrierSuperblock {
|
|
magic: *crate::carrier::CARRIER_MAGIC,
|
|
version: crate::carrier::CARRIER_VERSION_V2,
|
|
manifest_generation: 0,
|
|
total_blocks,
|
|
free_blocks: manifest.free_blocks.clone(),
|
|
next_inode_id: manifest.next_inode_id,
|
|
page_block_indices: vec![2],
|
|
};
|
|
let sb_bytes = serde_json::to_vec(&superblock)?;
|
|
|
|
// Block 0 schreiben (Primärkopie)
|
|
let (inner_ct_0, inner_nonce_0, inner_tag_0) =
|
|
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &sb_bytes, FORMAT_VERSION, 0)?;
|
|
let inner_ct_len_0 = inner_ct_0.len() as u32;
|
|
|
|
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE];
|
|
OsRng.fill_bytes(&mut outer_plaintext_0);
|
|
|
|
outer_plaintext_0[0..12].copy_from_slice(&inner_nonce_0);
|
|
outer_plaintext_0[12..28].copy_from_slice(&inner_tag_0);
|
|
outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
|
|
let ct_end_0 = 32 + inner_ct_0.len();
|
|
if ct_end_0 > CHUNK_SIZE {
|
|
bail!("Superblock-Payload zu groß für Block 0");
|
|
}
|
|
outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0);
|
|
|
|
let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk(
|
|
dek_0,
|
|
c_id,
|
|
0,
|
|
&outer_plaintext_0,
|
|
FORMAT_VERSION,
|
|
0,
|
|
)?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
|
params![
|
|
c_id,
|
|
outer_nonce_0.as_slice(),
|
|
outer_tag_0.as_slice(),
|
|
outer_ct_0
|
|
],
|
|
)?;
|
|
|
|
// Block 1 schreiben (Redundante Zweitkopie, C-02)
|
|
let (inner_ct_1, inner_nonce_1, inner_tag_1) =
|
|
crate::crypto::encrypt_chunk(dek_1, c_id, 1, &sb_bytes, FORMAT_VERSION, 0)?;
|
|
let inner_ct_len_1 = inner_ct_1.len() as u32;
|
|
|
|
let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE];
|
|
OsRng.fill_bytes(&mut outer_plaintext_1);
|
|
|
|
outer_plaintext_1[0..12].copy_from_slice(&inner_nonce_1);
|
|
outer_plaintext_1[12..28].copy_from_slice(&inner_tag_1);
|
|
outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes());
|
|
let ct_end_1 = 32 + inner_ct_1.len();
|
|
if ct_end_1 > CHUNK_SIZE {
|
|
bail!("Superblock-Payload zu groß für Block 1");
|
|
}
|
|
outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1);
|
|
|
|
let (outer_ct_1, outer_nonce_1, outer_tag_1) = crate::crypto::encrypt_chunk(
|
|
dek_0,
|
|
c_id,
|
|
1,
|
|
&outer_plaintext_1,
|
|
FORMAT_VERSION,
|
|
0,
|
|
)?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, 1, 0, ?2, ?3, ?4)",
|
|
params![
|
|
c_id,
|
|
outer_nonce_1.as_slice(),
|
|
outer_tag_1.as_slice(),
|
|
outer_ct_1
|
|
],
|
|
)?;
|
|
|
|
// 3. Blöcke 3..total_blocks-1 mit DEK_0 vorallokieren
|
|
let mut chunk_stmt = conn.prepare(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, ?2, 0, ?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 3..total_blocks {
|
|
let (ct, nonce, tag) =
|
|
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
|
|
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;")?;
|
|
drop(chunk_stmt);
|
|
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
self.set_active_slot_and_dek(1, Zeroizing::new(*dek_1));
|
|
let _ = self.update_metadata_mac();
|
|
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
|
return Ok(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
|
|
};
|
|
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
|
|
Ok(carrier_node_id)
|
|
}
|
|
|
|
/// Initialisiert das Datenbankschema mit Unterstützung für Dual-Vault (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();
|
|
|
|
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,
|
|
metadata_mac BLOB,
|
|
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
|
lock_pid INTEGER,
|
|
lock_host TEXT,
|
|
lock_time INTEGER
|
|
);
|
|
|
|
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,
|
|
is_carrier INTEGER NOT NULL DEFAULT 0,
|
|
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,
|
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
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 CSPRNG-Rauschen (Dummy-Slot-Längenparität)
|
|
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(),
|
|
],
|
|
)?;
|
|
}
|
|
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot-Längenparität).
|
|
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).
|
|
/// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch.
|
|
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
|
|
let conn = self.conn();
|
|
|
|
// SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS)
|
|
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?;
|
|
if slot_count == 0 {
|
|
bail!("Container-Header ist leer oder beschädigt");
|
|
}
|
|
if slot_count > 2 {
|
|
bail!(
|
|
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
|
slot_count
|
|
);
|
|
}
|
|
|
|
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 mut rows = stmt.query([])?;
|
|
let mut slots = Vec::new();
|
|
let mut seen_slot_ids = std::collections::HashSet::new();
|
|
|
|
while let Some(row) = rows.next()? {
|
|
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)?;
|
|
|
|
// SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig
|
|
if slot_id > 1 {
|
|
bail!(
|
|
"Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt",
|
|
slot_id
|
|
);
|
|
}
|
|
if !seen_slot_ids.insert(slot_id) {
|
|
bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id);
|
|
}
|
|
|
|
if salt_vec.len() != 16 {
|
|
bail!(
|
|
"Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
|
slot_id,
|
|
salt_vec.len()
|
|
);
|
|
}
|
|
let mut kdf_salt = [0u8; 16];
|
|
kdf_salt.copy_from_slice(&salt_vec);
|
|
|
|
if nonce_vec.len() != 12 {
|
|
bail!(
|
|
"Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)",
|
|
slot_id,
|
|
nonce_vec.len()
|
|
);
|
|
}
|
|
let mut header_nonce = [0u8; 12];
|
|
header_nonce.copy_from_slice(&nonce_vec);
|
|
|
|
if tag_vec.len() != 16 {
|
|
bail!(
|
|
"Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
|
slot_id,
|
|
tag_vec.len()
|
|
);
|
|
}
|
|
let mut header_tag = [0u8; 16];
|
|
header_tag.copy_from_slice(&tag_vec);
|
|
|
|
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
|
|
if slot_id == 0 {
|
|
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
|
bail!(
|
|
"Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)",
|
|
wrapped_dek.len()
|
|
);
|
|
}
|
|
} else if slot_id == 1 {
|
|
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
|
|
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
|
|
}
|
|
}
|
|
|
|
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).map_err(|e| {
|
|
anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id)
|
|
})?;
|
|
|
|
validate_kdf_params(&kdf_params)
|
|
.map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?;
|
|
|
|
slots.push(SlotMeta {
|
|
slot_id,
|
|
version,
|
|
kdf_salt,
|
|
kdf_params,
|
|
wrapped_dek,
|
|
header_nonce,
|
|
header_tag,
|
|
});
|
|
}
|
|
|
|
// SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich
|
|
if !seen_slot_ids.contains(&0) {
|
|
bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt");
|
|
}
|
|
|
|
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();
|
|
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
|
|
&& slot0.version != FORMAT_VERSION_V3
|
|
{
|
|
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();
|
|
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();
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Verschlüsselt alle Chunks eines Vaults mit einem neuen DEK um (K-03 Rekeying).
|
|
pub fn rekey_vault(
|
|
&self,
|
|
slot_id: u32,
|
|
old_dek: &[u8; 32],
|
|
new_dek: &[u8; 32],
|
|
version: u32,
|
|
) -> Result<()> {
|
|
let mut conn = self.conn();
|
|
let tx = conn.transaction()?;
|
|
|
|
// Ermittle alle Node-IDs dieses Vaults
|
|
let node_ids: Vec<i64> = if slot_id == 1 {
|
|
let mut stmt = tx.prepare(
|
|
"WITH RECURSIVE vault1(id) AS (
|
|
SELECT 2
|
|
UNION ALL
|
|
SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
|
|
) SELECT id FROM vault1",
|
|
)?;
|
|
let rows = stmt.query_map([], |r| r.get(0))?;
|
|
rows.filter_map(|r| r.ok()).collect()
|
|
} else {
|
|
let mut stmt = tx.prepare(
|
|
"WITH RECURSIVE vault0(id) AS (
|
|
SELECT 1
|
|
UNION ALL
|
|
SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id
|
|
) SELECT id FROM vault0",
|
|
)?;
|
|
let rows = stmt.query_map([], |r| r.get(0))?;
|
|
rows.filter_map(|r| r.ok()).collect()
|
|
};
|
|
|
|
// Option B: Falls Slot 1, verschlüsselte Dateinamen unter parent_id = 2 umverschlüsseln
|
|
if slot_id == 1 {
|
|
let mut name_stmt =
|
|
tx.prepare("SELECT id, parent_id, name FROM nodes WHERE parent_id = 2")?;
|
|
let node_rows: Vec<(i64, i64, String)> = name_stmt
|
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
drop(name_stmt);
|
|
|
|
for (id, parent_id, enc_name) in node_rows {
|
|
if let Some(decrypted_name) =
|
|
crate::crypto::decrypt_node_name(old_dek, parent_id, &enc_name)
|
|
{
|
|
let new_enc =
|
|
crate::crypto::encrypt_node_name(new_dek, parent_id, &decrypted_name);
|
|
tx.execute(
|
|
"UPDATE nodes SET name = ?1 WHERE id = ?2",
|
|
params![new_enc, id],
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Chunks der betroffenen Nodes umverschlüsseln
|
|
for nid in node_ids {
|
|
let mut chunk_stmt = tx.prepare(
|
|
"SELECT chunk_index, generation, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1",
|
|
)?;
|
|
let chunk_rows: Vec<(u32, u64, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
|
|
.query_map(params![nid], |row| {
|
|
let chunk_index: u32 = row.get(0)?;
|
|
let generation: i64 = row.get(1).unwrap_or(0);
|
|
let nonce_vec: Vec<u8> = row.get(2)?;
|
|
let tag_vec: Vec<u8> = row.get(3)?;
|
|
let ciphertext: Vec<u8> = row.get(4)?;
|
|
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((chunk_index, generation as u64, nonce, tag, ciphertext))
|
|
})?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
drop(chunk_stmt);
|
|
|
|
for (chunk_index, generation, nonce, tag, ct) in chunk_rows {
|
|
let plaintext = crate::crypto::decrypt_chunk(
|
|
old_dek,
|
|
nid,
|
|
chunk_index,
|
|
&ct,
|
|
&nonce,
|
|
&tag,
|
|
version,
|
|
generation,
|
|
)?;
|
|
let (new_ct, new_nonce, new_tag) = crate::crypto::encrypt_chunk(
|
|
new_dek,
|
|
nid,
|
|
chunk_index,
|
|
&plaintext,
|
|
version,
|
|
generation,
|
|
)?;
|
|
tx.execute(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5",
|
|
params![
|
|
new_nonce.as_slice(),
|
|
new_tag.as_slice(),
|
|
new_ct,
|
|
nid,
|
|
chunk_index,
|
|
],
|
|
)?;
|
|
}
|
|
}
|
|
|
|
tx.commit()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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();
|
|
|
|
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 = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) {
|
|
Some(name) => name,
|
|
None => {
|
|
warn!(
|
|
"Knoten {} in Pfadauflösung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
|
|
id
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
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();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
|
FROM nodes WHERE id = ?1",
|
|
)?;
|
|
|
|
let raw = stmt
|
|
.query_row(params![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,
|
|
))
|
|
})
|
|
.optional()?;
|
|
|
|
match raw {
|
|
Some((id, parent_id, enc_name, is_dir, size, created_at, modified_at)) => {
|
|
let name = if vault_id == 1 && parent_id.is_some() {
|
|
match decrypt_node_name(dek, parent_id.unwrap_or(0), &enc_name) {
|
|
Some(dec) => dec,
|
|
None => {
|
|
warn!(
|
|
"Knoten {} kann nicht geladen werden: Name konnte nicht entschlüsselt werden (ST-01)",
|
|
id
|
|
);
|
|
return Ok(None);
|
|
}
|
|
}
|
|
} else {
|
|
enc_name
|
|
};
|
|
|
|
Ok(Some(NodeRecord {
|
|
id,
|
|
parent_id,
|
|
name,
|
|
is_dir,
|
|
size,
|
|
created_at,
|
|
modified_at,
|
|
}))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
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();
|
|
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| {
|
|
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 entries = Vec::new();
|
|
for r in rows {
|
|
let (id, p_id, enc_name, is_dir, size, created_at, modified_at) = r?;
|
|
let name = if vault_id == 1 {
|
|
match decrypt_node_name(dek, parent_id, &enc_name) {
|
|
Some(dec) => dec,
|
|
None => {
|
|
warn!(
|
|
"Knoten {} in Verzeichnisauflistung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
|
|
id
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
enc_name
|
|
};
|
|
|
|
entries.push(NodeRecord {
|
|
id,
|
|
parent_id: p_id,
|
|
name,
|
|
is_dir,
|
|
size,
|
|
created_at,
|
|
modified_at,
|
|
});
|
|
}
|
|
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> {
|
|
crate::pathutil::validate_node_name(name)?;
|
|
let now = current_timestamp();
|
|
let conn = self.conn();
|
|
|
|
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],
|
|
);
|
|
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
|
|
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();
|
|
conn.execute(
|
|
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
|
|
params![size as i64, modified_at as i64, id],
|
|
)?;
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
Ok(())
|
|
}
|
|
|
|
/// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen
|
|
/// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig
|
|
/// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03).
|
|
pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> {
|
|
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
|
if node_id == carrier_id {
|
|
bail!(
|
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).",
|
|
node_id
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_subtree_tx(
|
|
tx: &rusqlite::Transaction<'_>,
|
|
id: i64,
|
|
carrier_id: Option<i64>,
|
|
) -> Result<()> {
|
|
if let Some(cid) = carrier_id {
|
|
if id == cid {
|
|
bail!(
|
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
|
|
// 1. Chunks für diesen Knoten kryptografisch shreddern
|
|
{
|
|
let mut stmt = tx
|
|
.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
|
let chunks: Vec<(u32, usize)> = stmt
|
|
.query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
let mut update_stmt = tx.prepare(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
|
)?;
|
|
|
|
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 _ = update_stmt.execute(params![
|
|
nonce_noise.as_slice(),
|
|
tag_noise.as_slice(),
|
|
noise,
|
|
id,
|
|
chunk_idx
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 2. Kindknoten ermitteln und rekursiv innerhalb derselben Transaktion löschen
|
|
let child_ids: Vec<i64> = {
|
|
let mut stmt = tx.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
|
|
};
|
|
|
|
let mut errors = Vec::new();
|
|
for child_id in child_ids {
|
|
if let Err(e) = Self::delete_subtree_tx(tx, child_id, carrier_id) {
|
|
error!("Konnte Kindknoten {} nicht löschen: {e}", child_id);
|
|
errors.push(e);
|
|
}
|
|
}
|
|
if !errors.is_empty() {
|
|
bail!(
|
|
"Fehler beim rekursiven Löschen: {} Kindknoten konnten nicht gelöscht werden (ST-02)",
|
|
errors.len()
|
|
);
|
|
}
|
|
|
|
// 3. Chunks und Knoten aus der Datenbank löschen
|
|
tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
|
tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar in einer SQLite-Transaktion (ST-03).
|
|
pub fn delete_node(&self, id: i64) -> Result<()> {
|
|
self.assert_not_carrier(id)?;
|
|
|
|
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
|
let carrier_id = self.find_carrier_node_id().ok().flatten();
|
|
if let Some(cid) = carrier_id {
|
|
if self.is_descendant_of(cid, id).unwrap_or(false) {
|
|
bail!(
|
|
"Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.",
|
|
cid
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut conn = self.conn();
|
|
let tx = conn.transaction()?;
|
|
Self::delete_subtree_tx(&tx, id, carrier_id)?;
|
|
tx.commit()?;
|
|
drop(conn);
|
|
|
|
let _ = self.update_metadata_mac();
|
|
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<()> {
|
|
self.assert_not_carrier(id)?;
|
|
crate::pathutil::validate_node_name(new_name)?;
|
|
let now = current_timestamp();
|
|
let conn = self.conn();
|
|
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],
|
|
)?;
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
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();
|
|
let mut stmt = conn.prepare(
|
|
"SELECT nonce, tag, ciphertext, generation 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 generation: i64 = row.get(3).unwrap_or(0);
|
|
|
|
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,
|
|
generation: generation as u64,
|
|
nonce,
|
|
tag,
|
|
ciphertext,
|
|
})
|
|
})
|
|
.optional()?;
|
|
|
|
Ok(record)
|
|
}
|
|
|
|
/// Ermittelt die nächste Generation für einen Chunk (K-02 Chunk-Replay-Schutz).
|
|
/// Garantiert eine strikt monoton steigende Generation containerweit.
|
|
pub fn next_chunk_generation(&self, node_id: i64, chunk_index: u32) -> Result<u64> {
|
|
let conn = self.conn();
|
|
let current_gen: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
|
|
params![node_id, chunk_index],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
let max_gen: i64 = conn
|
|
.query_row("SELECT COALESCE(MAX(generation), 0) FROM chunks", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap_or(0);
|
|
let next = match current_gen {
|
|
Some(g) => (g + 1).max(max_gen + 1),
|
|
None => max_gen + 1,
|
|
};
|
|
Ok(next as u64)
|
|
}
|
|
|
|
/// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
|
|
pub fn write_chunk(
|
|
&self,
|
|
node_id: i64,
|
|
chunk_index: u32,
|
|
generation: u64,
|
|
nonce: &[u8; 12],
|
|
tag: &[u8; 16],
|
|
ciphertext: &[u8],
|
|
) -> Result<()> {
|
|
let conn = self.conn();
|
|
conn.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
|
|
generation = excluded.generation,
|
|
nonce = excluded.nonce,
|
|
tag = excluded.tag,
|
|
ciphertext = excluded.ciphertext",
|
|
params![
|
|
node_id,
|
|
chunk_index,
|
|
generation as i64,
|
|
nonce.as_slice(),
|
|
tag.as_slice(),
|
|
ciphertext,
|
|
],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar
|
|
/// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01).
|
|
pub fn write_chunk_and_update_size(
|
|
&self,
|
|
node_id: i64,
|
|
chunk_index: u32,
|
|
generation: u64,
|
|
nonce: &[u8; 12],
|
|
tag: &[u8; 16],
|
|
ciphertext: &[u8],
|
|
new_size: u64,
|
|
modified_at: u64,
|
|
) -> Result<()> {
|
|
self.assert_not_carrier(node_id)?;
|
|
let mut conn = self.conn();
|
|
let tx = conn.transaction()?;
|
|
tx.execute(
|
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
|
|
generation = excluded.generation,
|
|
nonce = excluded.nonce,
|
|
tag = excluded.tag,
|
|
ciphertext = excluded.ciphertext",
|
|
params![
|
|
node_id,
|
|
chunk_index,
|
|
generation as i64,
|
|
nonce.as_slice(),
|
|
tag.as_slice(),
|
|
ciphertext,
|
|
],
|
|
)?;
|
|
tx.execute(
|
|
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
|
|
params![new_size as i64, modified_at as i64, node_id],
|
|
)?;
|
|
tx.commit()?;
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
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<()> {
|
|
self.assert_not_carrier(node_id)?;
|
|
let mut conn = self.conn();
|
|
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| {
|
|
let idx: u32 = row.get(0)?;
|
|
let len: usize = row.get::<_, Option<usize>>(1)?.unwrap_or(0);
|
|
Ok((idx, len))
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?;
|
|
|
|
let mut update_stmt = tx.prepare(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 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);
|
|
|
|
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()?;
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
Ok(())
|
|
}
|
|
|
|
/// Löscht und shreddert alle Chunks eines Knotens (z. B. beim Kürzen auf 0 Bytes) (S-05).
|
|
pub fn delete_all_chunks(&self, node_id: i64) -> Result<()> {
|
|
self.assert_not_carrier(node_id)?;
|
|
let mut conn = self.conn();
|
|
let tx = conn.transaction()?;
|
|
{
|
|
let mut stmt = tx
|
|
.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
|
let chunks_to_shred: Vec<(u32, usize)> = stmt
|
|
.query_map(params![node_id], |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", params![node_id])?;
|
|
tx.commit()?;
|
|
drop(conn);
|
|
let _ = self.update_metadata_mac();
|
|
Ok(())
|
|
}
|
|
|
|
/// Prüft den aktuellen Advisory-Lock-Status (S-06).
|
|
/// Gibt `Some((pid, host, timestamp))` zurück, falls ein Lock aktiv und der Prozess noch am Leben ist.
|
|
pub fn check_advisory_lock(&self) -> Result<Option<(u32, String, u64)>> {
|
|
let conn = self.conn();
|
|
let mut stmt = conn
|
|
.prepare("SELECT lock_pid, lock_host, lock_time FROM meta WHERE slot_id = 0 LIMIT 1")?;
|
|
let lock_info = stmt
|
|
.query_row([], |row| {
|
|
let pid: Option<i64> = row.get(0)?;
|
|
let host: Option<String> = row.get(1)?;
|
|
let time: Option<i64> = row.get(2)?;
|
|
Ok((pid, host, time))
|
|
})
|
|
.optional()?;
|
|
|
|
if let Some((Some(pid), Some(host), Some(time))) = lock_info {
|
|
let current_host = std::env::var("COMPUTERNAME")
|
|
.or_else(|_| std::env::var("HOSTNAME"))
|
|
.unwrap_or_else(|_| "localhost".to_string());
|
|
|
|
// Falls gleicher Host, prüfe ob PID noch läuft
|
|
if host == current_host {
|
|
if crate::platform::is_process_alive(pid as u32) {
|
|
return Ok(Some((pid as u32, host, time as u64)));
|
|
} else {
|
|
// Verwaister Lock von abgestürztem Prozess -> ignorieren/löschen
|
|
drop(stmt);
|
|
drop(conn);
|
|
let _ = self.release_advisory_lock();
|
|
return Ok(None);
|
|
}
|
|
} else {
|
|
return Ok(Some((pid as u32, host, time as u64)));
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Setzt einen Advisory Lock auf den Container (S-06).
|
|
pub fn acquire_advisory_lock(&self, force: bool) -> Result<()> {
|
|
if !force {
|
|
if let Some((pid, host, time)) = self.check_advisory_lock()? {
|
|
bail!(
|
|
"Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.",
|
|
pid, host, time
|
|
);
|
|
}
|
|
}
|
|
|
|
let pid = std::process::id();
|
|
let host = std::env::var("COMPUTERNAME")
|
|
.or_else(|_| std::env::var("HOSTNAME"))
|
|
.unwrap_or_else(|_| "localhost".to_string());
|
|
let now = current_timestamp();
|
|
|
|
let conn = self.conn();
|
|
conn.execute(
|
|
"UPDATE meta SET lock_pid = ?1, lock_host = ?2, lock_time = ?3 WHERE slot_id = 0",
|
|
params![pid as i64, host, now],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Entfernt den Advisory Lock (S-06).
|
|
pub fn release_advisory_lock(&self) -> Result<()> {
|
|
let conn = self.conn();
|
|
conn.execute(
|
|
"UPDATE meta SET lock_pid = NULL, lock_host = NULL, lock_time = NULL WHERE slot_id = 0",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Setzt einen Advisory Lock und gibt einen RAII Guard zurück (S-06).
|
|
pub fn acquire_advisory_lock_guard(&self, force: bool) -> Result<AdvisoryLockGuard> {
|
|
self.acquire_advisory_lock(force)?;
|
|
Ok(AdvisoryLockGuard { db: self.clone() })
|
|
}
|
|
|
|
/// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01).
|
|
pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> {
|
|
self.canonical_nodes_bytes_for_vault(0)
|
|
}
|
|
|
|
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault.
|
|
pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result<Vec<u8>> {
|
|
let conn = self.conn();
|
|
let mut buf = Vec::new();
|
|
|
|
if vault_id == 1 {
|
|
let mut stmt = conn.prepare(
|
|
"WITH RECURSIVE vault1(id) AS (
|
|
SELECT 2
|
|
UNION ALL
|
|
SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
|
|
)
|
|
SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier,
|
|
(SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count
|
|
FROM nodes n
|
|
WHERE n.id IN (SELECT id FROM vault1)
|
|
ORDER BY n.id ASC",
|
|
)?;
|
|
let mut rows = stmt.query([])?;
|
|
while let Some(row) = rows.next()? {
|
|
Self::serialize_node_row(&row, &mut buf)?;
|
|
}
|
|
} else {
|
|
let mut stmt = conn.prepare(
|
|
"WITH RECURSIVE vault0(id) AS (
|
|
SELECT 1
|
|
UNION ALL
|
|
SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id
|
|
)
|
|
SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier,
|
|
(SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count
|
|
FROM nodes n
|
|
WHERE n.id = 2 OR n.id IN (SELECT id FROM vault0)
|
|
ORDER BY n.id ASC",
|
|
)?;
|
|
let mut rows = stmt.query([])?;
|
|
while let Some(row) = rows.next()? {
|
|
Self::serialize_node_row(&row, &mut buf)?;
|
|
}
|
|
}
|
|
|
|
Ok(buf)
|
|
}
|
|
|
|
fn serialize_node_row(row: &rusqlite::Row, buf: &mut Vec<u8>) -> Result<()> {
|
|
let id: i64 = row.get(0)?;
|
|
let parent_id: Option<i64> = row.get(1)?;
|
|
let name: String = row.get(2)?;
|
|
let is_dir: i64 = row.get(3)?;
|
|
let size: i64 = row.get(4)?;
|
|
let created_at: i64 = row.get(5)?;
|
|
let modified_at: i64 = row.get(6)?;
|
|
let is_carrier: i64 = row.get(7)?;
|
|
let chunk_count: i64 = row.get(8)?;
|
|
|
|
buf.extend_from_slice(&id.to_le_bytes());
|
|
match parent_id {
|
|
Some(pid) => {
|
|
buf.push(1u8);
|
|
buf.extend_from_slice(&pid.to_le_bytes());
|
|
}
|
|
None => {
|
|
buf.push(0u8);
|
|
buf.extend_from_slice(&0i64.to_le_bytes());
|
|
}
|
|
}
|
|
let name_bytes = name.as_bytes();
|
|
buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
|
|
buf.extend_from_slice(name_bytes);
|
|
buf.push(if is_dir != 0 { 1u8 } else { 0u8 });
|
|
buf.extend_from_slice(&size.to_le_bytes());
|
|
buf.extend_from_slice(&created_at.to_le_bytes());
|
|
buf.extend_from_slice(&modified_at.to_le_bytes());
|
|
buf.push(if is_carrier != 0 { 1u8 } else { 0u8 });
|
|
buf.extend_from_slice(&chunk_count.to_le_bytes());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01).
|
|
pub fn update_metadata_mac(&self) -> Result<()> {
|
|
let session_opt = self
|
|
.active_session
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.clone();
|
|
let Some((slot_id, dek)) = session_opt else {
|
|
return Ok(());
|
|
};
|
|
|
|
let conn = self.conn();
|
|
let version_and_gen: Option<(u32, u64)> = conn
|
|
.query_row(
|
|
"SELECT version, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1",
|
|
params![slot_id],
|
|
|r| Ok((r.get(0)?, r.get(1).unwrap_or(0))),
|
|
)
|
|
.optional()?;
|
|
|
|
let Some((version, current_gen)) = version_and_gen else {
|
|
return Ok(());
|
|
};
|
|
|
|
if version < FORMAT_VERSION_V3 {
|
|
return Ok(());
|
|
}
|
|
|
|
drop(conn);
|
|
|
|
let next_gen = current_gen + 1;
|
|
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
|
let mac_key = derive_metadata_mac_key(&dek);
|
|
let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical);
|
|
|
|
let conn = self.conn();
|
|
conn.execute(
|
|
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3",
|
|
params![new_mac.as_slice(), next_gen, slot_id],
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Prüft die Integrität des Metadaten-MAC gegen den gegebenen DEK (Format V3 / K-01).
|
|
pub fn verify_metadata_mac(&self, dek: &[u8; 32]) -> Result<bool> {
|
|
if self.verify_metadata_mac_for_slot(0, dek)? {
|
|
return Ok(true);
|
|
}
|
|
if self.verify_metadata_mac_for_slot(1, dek)? {
|
|
return Ok(true);
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
/// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden).
|
|
pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result<bool> {
|
|
Ok(self.verify_metadata_mac_status_for_slot(slot_id, dek)? == MetadataMacStatus::Valid)
|
|
}
|
|
|
|
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1).
|
|
pub fn verify_metadata_mac_status_for_slot(
|
|
&self,
|
|
slot_id: u32,
|
|
dek: &[u8; 32],
|
|
) -> Result<MetadataMacStatus> {
|
|
let conn = self.conn();
|
|
let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn
|
|
.query_row(
|
|
"SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1",
|
|
params![slot_id],
|
|
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))),
|
|
)
|
|
.optional()?;
|
|
|
|
let Some((version, mac_opt, gen)) = meta_row else {
|
|
return Ok(MetadataMacStatus::Invalid);
|
|
};
|
|
|
|
if version < FORMAT_VERSION_V3 {
|
|
return Ok(MetadataMacStatus::Valid);
|
|
}
|
|
|
|
// R-NEW-1: Frisch restaurierter Container (gen == 0 && mac_opt IS NULL)
|
|
if gen == 0 && mac_opt.is_none() {
|
|
return Ok(MetadataMacStatus::PendingRebuild);
|
|
}
|
|
|
|
let Some(mac_bytes) = mac_opt else {
|
|
return Ok(MetadataMacStatus::Invalid);
|
|
};
|
|
|
|
if mac_bytes.len() != 32 {
|
|
return Ok(MetadataMacStatus::Invalid);
|
|
}
|
|
|
|
let mut expected_mac = [0u8; 32];
|
|
expected_mac.copy_from_slice(&mac_bytes);
|
|
drop(conn);
|
|
|
|
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
|
let mac_key = derive_metadata_mac_key(dek);
|
|
if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) {
|
|
Ok(MetadataMacStatus::Valid)
|
|
} else {
|
|
Ok(MetadataMacStatus::Invalid)
|
|
}
|
|
}
|
|
|
|
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
|
|
pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> {
|
|
let conn = self.conn();
|
|
let version: u32 = conn.query_row(
|
|
"SELECT version FROM meta WHERE slot_id = 0 LIMIT 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
|
|
if version >= FORMAT_VERSION_V3 {
|
|
return Ok(());
|
|
}
|
|
|
|
let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []);
|
|
let _ = conn.execute(
|
|
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
let _ = conn.execute(
|
|
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
|
|
// K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
|
|
{
|
|
let mut chunk_stmt =
|
|
conn.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
|
|
let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
|
|
.query_map([], |row| {
|
|
let node_id: i64 = row.get(0)?;
|
|
let chunk_index: u32 = row.get(1)?;
|
|
let nonce_vec: Vec<u8> = row.get(2)?;
|
|
let tag_vec: Vec<u8> = row.get(3)?;
|
|
let ciphertext: Vec<u8> = row.get(4)?;
|
|
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((node_id, chunk_index, nonce, tag, ciphertext))
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
drop(chunk_stmt);
|
|
|
|
for (node_id, chunk_index, nonce, tag, ct) in chunk_rows {
|
|
if let Ok(plaintext) = crate::crypto::decrypt_chunk(
|
|
dek,
|
|
node_id,
|
|
chunk_index,
|
|
&ct,
|
|
&nonce,
|
|
&tag,
|
|
version,
|
|
0,
|
|
) {
|
|
if let Ok((new_ct, new_nonce, new_tag)) = crate::crypto::encrypt_chunk(
|
|
dek,
|
|
node_id,
|
|
chunk_index,
|
|
&plaintext,
|
|
FORMAT_VERSION_V3,
|
|
0,
|
|
) {
|
|
conn.execute(
|
|
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
|
params![
|
|
new_nonce.as_slice(),
|
|
new_tag.as_slice(),
|
|
new_ct,
|
|
node_id,
|
|
chunk_index
|
|
],
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
conn.execute(
|
|
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
|
|
[FORMAT_VERSION_V3],
|
|
)?;
|
|
|
|
drop(conn);
|
|
|
|
self.set_active_dek(Zeroizing::new(*dek));
|
|
self.update_metadata_mac()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
|
pub fn checkpoint(&self) -> Result<()> {
|
|
let conn = self.conn();
|
|
let _res: (i64, i64, i64) =
|
|
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
|
})?;
|
|
let _ = conn.execute_batch("PRAGMA incremental_vacuum;");
|
|
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();
|
|
|
|
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();
|
|
|
|
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,
|
|
metadata_mac BLOB,
|
|
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
|
lock_pid INTEGER,
|
|
lock_host TEXT,
|
|
lock_time INTEGER
|
|
);",
|
|
)?;
|
|
|
|
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, metadata_mac, metadata_gen)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0)",
|
|
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, metadata_mac, metadata_gen)
|
|
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
|
|
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 Längenparität
|
|
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, metadata_mac, metadata_gen)
|
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
|
|
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();
|
|
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();
|
|
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();
|
|
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();
|
|
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, 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, 0, &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, 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_v09_truncate_chunks_after_shreds_before_deletion() {
|
|
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, "truncate_test.dat", false).unwrap();
|
|
|
|
let payload_0 = b"CHUNK_ZERO_MUST_SURVIVE";
|
|
let payload_1 = b"CHUNK_ONE_MUST_BE_SHREDDED_AND_DELETED";
|
|
let payload_2 = b"CHUNK_TWO_MUST_BE_SHREDDED_AND_DELETED";
|
|
|
|
db.write_chunk(file.id, 0, 0, &[1u8; 12], &[2u8; 16], payload_0)
|
|
.unwrap();
|
|
db.write_chunk(file.id, 1, 0, &[3u8; 12], &[4u8; 16], payload_1)
|
|
.unwrap();
|
|
db.write_chunk(file.id, 2, 0, &[5u8; 12], &[6u8; 16], payload_2)
|
|
.unwrap();
|
|
|
|
// Kürze alle Chunks nach Index 0 (also Chunks 1 und 2)
|
|
db.truncate_chunks_after(file.id, 0).unwrap();
|
|
|
|
// Chunk 0 muss unberührt geblieben sein
|
|
let c0 = db
|
|
.read_chunk(file.id, 0)
|
|
.unwrap()
|
|
.expect("Chunk 0 survives");
|
|
assert_eq!(c0.ciphertext, payload_0);
|
|
|
|
// Chunks 1 und 2 müssen gelöscht sein
|
|
assert!(db.read_chunk(file.id, 1).unwrap().is_none());
|
|
assert!(db.read_chunk(file.id, 2).unwrap().is_none());
|
|
}
|
|
|
|
#[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();
|
|
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();
|
|
// Dual-Vault Isolation: 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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_atomic_chunk_write_and_size_update() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
|
[1u8; 16],
|
|
KdfParams::default(),
|
|
vec![2u8; 40],
|
|
[3u8; 12],
|
|
[4u8; 16],
|
|
);
|
|
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
|
|
let file = db.create_node(1, "crash_test.bin", false).unwrap();
|
|
assert_eq!(file.size, 0);
|
|
|
|
let chunk_nonce = [5u8; 12];
|
|
let chunk_tag = [6u8; 16];
|
|
let ciphertext = vec![7u8; 1024];
|
|
let new_size = 1024u64;
|
|
let modified_at = 2000000u64;
|
|
|
|
db.write_chunk_and_update_size(
|
|
file.id,
|
|
0,
|
|
0,
|
|
&chunk_nonce,
|
|
&chunk_tag,
|
|
&ciphertext,
|
|
new_size,
|
|
modified_at,
|
|
)
|
|
.expect("Atomic write");
|
|
|
|
// Chunk verifizieren
|
|
let chunk = db
|
|
.read_chunk(file.id, 0)
|
|
.unwrap()
|
|
.expect("Chunk must exist");
|
|
assert_eq!(chunk.ciphertext, ciphertext);
|
|
assert_eq!(chunk.nonce, chunk_nonce);
|
|
assert_eq!(chunk.tag, chunk_tag);
|
|
|
|
// Inode verifizieren
|
|
let nodes = db.list_children(1).unwrap();
|
|
let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap();
|
|
assert_eq!(updated_file.size, new_size);
|
|
assert_eq!(updated_file.modified_at, modified_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_z02_poisoned_sqlite_mutex_recovery() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
|
[1u8; 16],
|
|
KdfParams::default(),
|
|
vec![2u8; 40],
|
|
[3u8; 12],
|
|
[4u8; 16],
|
|
);
|
|
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
|
|
// 1. Verifiziere normale Funktion
|
|
let node1 = db.create_node(1, "normal.txt", false).unwrap();
|
|
assert_eq!(node1.name, "normal.txt");
|
|
|
|
// 2. Simuliere Thread-Panic während gehaltener Mutex-Sperre auf self.conn
|
|
let db_clone = db.clone();
|
|
let handle = std::thread::spawn(move || {
|
|
let _guard = db_clone.conn();
|
|
panic!("Simulierter Crash im Worker-Thread während aktiver SQLite-Verbindung");
|
|
});
|
|
let res = handle.join();
|
|
assert!(
|
|
res.is_err(),
|
|
"Worker-Thread muss wie erwartet gepanict haben"
|
|
);
|
|
|
|
// 3. Mutex ist nun poisoned. Ohne Z-02 schlägt jeder nachfolgende Aufruf fehl.
|
|
// Mit Z-02 fängt unwrap_or_else(|e| e.into_inner()) das Poisoning ab:
|
|
let node2 = db.create_node(1, "after_poison.txt", false);
|
|
assert!(
|
|
node2.is_ok(),
|
|
"Z-02: Datenbankoperationen müssen trotz vergiftetem Mutex erfolgreich wiederhergestellt werden"
|
|
);
|
|
assert_eq!(node2.unwrap().name, "after_poison.txt");
|
|
|
|
// 4. Prüfe auch active_session Poisoning
|
|
let db_clone2 = db.clone();
|
|
let handle2 = std::thread::spawn(move || {
|
|
let _guard = db_clone2.active_session.lock().unwrap();
|
|
panic!("Simulierter Crash während active_session Sperre");
|
|
});
|
|
let _ = handle2.join();
|
|
|
|
// Setzen und Lesen muss weiterhin fehlerfrei funktionieren
|
|
let test_dek = Zeroizing::new([42u8; 32]);
|
|
db.set_active_dek(test_dek);
|
|
let read_dek = db.active_dek();
|
|
assert!(read_dek.is_some());
|
|
assert_eq!(read_dek.unwrap()[0], 42);
|
|
}
|
|
|
|
#[test]
|
|
fn test_st01_fail_closed_name_decryption_no_hex_leak() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let (salt0, kdf0) = ([1u8; 16], KdfParams::default());
|
|
let (salt1, kdf1) = ([2u8; 16], KdfParams::default());
|
|
let (dek0, dek1) = ([10u8; 32], [20u8; 32]);
|
|
let carrier_name = "backup.iso";
|
|
let carrier_size = 5 * 1024 * 1024;
|
|
|
|
db.init_schema_with_carrier(
|
|
&salt0,
|
|
&kdf0,
|
|
&[0u8; 40],
|
|
&[0u8; 12],
|
|
&[0u8; 16],
|
|
Some((
|
|
carrier_name,
|
|
carrier_size,
|
|
&salt1,
|
|
&kdf1,
|
|
&[1u8; 40],
|
|
&[1u8; 12],
|
|
&[1u8; 16],
|
|
&dek0,
|
|
&dek1,
|
|
)),
|
|
)
|
|
.unwrap();
|
|
|
|
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().unwrap();
|
|
assert_eq!(root1.id, 2);
|
|
|
|
// 1. Zwei Dateien in Vault 1 anlegen
|
|
let node_valid = db
|
|
.create_node_in_vault(1, root1.id, "secret_valid.txt", false, &dek1)
|
|
.unwrap();
|
|
let node_corrupt = db
|
|
.create_node_in_vault(1, root1.id, "secret_corrupt.txt", false, &dek1)
|
|
.unwrap();
|
|
|
|
// 2. Vor Korruption: Beide Dateien sichtbar
|
|
let children_before = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
|
|
assert_eq!(children_before.len(), 2);
|
|
|
|
// 3. Simuliere Bitrot / Manipulation: Überschreibe den verschlüsselten Namen in der DB
|
|
{
|
|
let conn = db.conn();
|
|
conn.execute(
|
|
"UPDATE nodes SET name = 'deadbeef_invalid_ciphertext_without_valid_tag' WHERE id = ?1",
|
|
params![node_corrupt.id],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// 4. ST-01 Prüfungen (Fail-Closed):
|
|
// a) list_children_in_vault filtert den korrupten Knoten heraus
|
|
let children_after = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
|
|
assert_eq!(
|
|
children_after.len(),
|
|
1,
|
|
"ST-01: Korrupter Knoten muss aus der Liste herausgefiltert werden"
|
|
);
|
|
assert_eq!(children_after[0].id, node_valid.id);
|
|
assert_eq!(children_after[0].name, "secret_valid.txt");
|
|
assert!(
|
|
!children_after.iter().any(|c| c.name.contains("deadbeef")),
|
|
"Kein Hex-Ciphertext-Leak"
|
|
);
|
|
|
|
// b) resolve_path_in_vault gibt None zurück (nicht den Hex-Namen)
|
|
let resolved = db
|
|
.resolve_path_in_vault("/secret_corrupt.txt", 1, &dek1)
|
|
.unwrap();
|
|
assert!(
|
|
resolved.is_none(),
|
|
"ST-01: Pfad mit korruptem Namen darf nicht aufgelöst werden"
|
|
);
|
|
|
|
// c) get_node_by_id_in_vault gibt None zurück (fail-closed statt Hex-String)
|
|
let by_id = db
|
|
.get_node_by_id_in_vault(node_corrupt.id, 1, &dek1)
|
|
.unwrap();
|
|
assert!(
|
|
by_id.is_none(),
|
|
"ST-01: get_node_by_id_in_vault muss None zurückgeben, wenn Name unlesbar ist"
|
|
);
|
|
|
|
// d) Gültiger Knoten kann normal geladen werden
|
|
let valid_by_id = db.get_node_by_id_in_vault(node_valid.id, 1, &dek1).unwrap();
|
|
assert!(valid_by_id.is_some());
|
|
assert_eq!(valid_by_id.unwrap().name, "secret_valid.txt");
|
|
}
|
|
|
|
#[test]
|
|
fn test_st02_recursive_delete_error_propagation_not_swallowed() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
|
[1u8; 16],
|
|
KdfParams::default(),
|
|
vec![2u8; 40],
|
|
[3u8; 12],
|
|
[4u8; 16],
|
|
);
|
|
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
|
|
// Verzeichnisstruktur erstellen:
|
|
// /parent (id=3)
|
|
// /parent/normal.txt (id=4)
|
|
// /parent/protected.txt (id=5)
|
|
let parent = db.create_node(1, "parent", true).unwrap();
|
|
let _child_normal = db.create_node(parent.id, "normal.txt", false).unwrap();
|
|
let _child_protected = db.create_node(parent.id, "protected.txt", false).unwrap();
|
|
|
|
// SQLite-Trigger einrichten, der das Löschen von 'protected.txt' blockiert
|
|
{
|
|
let conn = db.conn();
|
|
conn.execute(
|
|
"CREATE TRIGGER block_delete_protected BEFORE DELETE ON nodes
|
|
WHEN OLD.name = 'protected.txt'
|
|
BEGIN
|
|
SELECT RAISE(FAIL, 'Löschen von protected.txt blockiert');
|
|
END;",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// ST-02: delete_node(parent.id) darf den Fehler nicht mehr mit `let _ =` verschlucken!
|
|
let res = db.delete_node(parent.id);
|
|
assert!(
|
|
res.is_err(),
|
|
"Löschen muss fehlschlagen, wenn ein Kindknoten nicht gelöscht werden kann"
|
|
);
|
|
let err_msg = res.unwrap_err().to_string();
|
|
assert!(
|
|
err_msg.contains(
|
|
"Fehler beim rekursiven Löschen: 1 Kindknoten konnten nicht gelöscht werden"
|
|
),
|
|
"Fehlermeldung muss die Anzahl fehlgeschlagener Kindknoten exakt ausweisen: {}",
|
|
err_msg
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_st03_recursive_delete_transaction_rollback() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
|
[1u8; 16],
|
|
KdfParams::default(),
|
|
vec![2u8; 40],
|
|
[3u8; 12],
|
|
[4u8; 16],
|
|
);
|
|
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
|
|
// 1. Ordner mit zwei Dateien anlegen
|
|
let folder = db.create_node(1, "folder", true).unwrap();
|
|
let file1 = db.create_node(folder.id, "file1.txt", false).unwrap();
|
|
let file2 = db.create_node(folder.id, "file2.txt", false).unwrap();
|
|
|
|
// Chunks für file1 und file2 anlegen
|
|
let chunk_data = vec![0x42u8; 1024];
|
|
let nonce = [1u8; 12];
|
|
let tag = [2u8; 16];
|
|
db.write_chunk(file1.id, 0, 1, &nonce, &tag, &chunk_data)
|
|
.unwrap();
|
|
db.write_chunk(file2.id, 0, 1, &nonce, &tag, &chunk_data)
|
|
.unwrap();
|
|
|
|
// 2. Trigger erstellen, der Löschen von file2 verhindert
|
|
{
|
|
let conn = db.conn();
|
|
conn.execute(
|
|
"CREATE TRIGGER prevent_del_file2 BEFORE DELETE ON nodes
|
|
WHEN OLD.name = 'file2.txt'
|
|
BEGIN
|
|
SELECT RAISE(FAIL, 'file2 ist schreibgeschützt');
|
|
END;",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// 3. Löschen des gesamten Ordners aufrufen -> schlägt fehl
|
|
let res = db.delete_node(folder.id);
|
|
assert!(res.is_err(), "delete_node muss fehlschlagen");
|
|
|
|
// 4. ST-03 Atomaritätsprüfung (Rollback):
|
|
// Weder folder noch file1 noch file2 dürfen gelöscht worden sein!
|
|
assert!(
|
|
db.get_node_by_id(folder.id).unwrap().is_some(),
|
|
"Folder darf nach Rollback nicht gelöscht sein"
|
|
);
|
|
assert!(
|
|
db.get_node_by_id(file1.id).unwrap().is_some(),
|
|
"file1 darf nach Rollback nicht gelöscht sein"
|
|
);
|
|
assert!(
|
|
db.get_node_by_id(file2.id).unwrap().is_some(),
|
|
"file2 darf nach Rollback nicht gelöscht sein"
|
|
);
|
|
|
|
// Chunk von file1 muss unversehrt existieren
|
|
let chunk1 = db.read_chunk(file1.id, 0).unwrap();
|
|
assert!(
|
|
chunk1.is_some(),
|
|
"Chunk von file1 darf nach Rollback nicht gelöscht sein"
|
|
);
|
|
assert_eq!(
|
|
chunk1.unwrap().ciphertext,
|
|
chunk_data,
|
|
"Chunk von file1 muss unversehrt sein"
|
|
);
|
|
|
|
// 5. Trigger entfernen und erneut löschen -> muss jetzt vollständig gelingen
|
|
{
|
|
let conn = db.conn();
|
|
conn.execute("DROP TRIGGER prevent_del_file2", []).unwrap();
|
|
}
|
|
db.delete_node(folder.id)
|
|
.expect("delete_node nach Trigger-Entfernung muss gelingen");
|
|
|
|
assert!(
|
|
db.get_node_by_id(folder.id).unwrap().is_none(),
|
|
"Folder muss gelöscht sein"
|
|
);
|
|
assert!(
|
|
db.get_node_by_id(file1.id).unwrap().is_none(),
|
|
"file1 muss gelöscht sein"
|
|
);
|
|
assert!(
|
|
db.get_node_by_id(file2.id).unwrap().is_none(),
|
|
"file2 muss gelöscht sein"
|
|
);
|
|
assert!(
|
|
db.read_chunk(file1.id, 0).unwrap().is_none(),
|
|
"Chunk von file1 muss gelöscht sein"
|
|
);
|
|
}
|
|
}
|