Files
sanctum/src/storage.rs
T
harald badbe3bd18
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
fix(recovery): resolve metadata MAC desync after recovery (R-NEW-1) & complete release workflow (CI-01)
- 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
2026-09-21 08:15:17 +02:00

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