feat(security): implement Phase 1 of Plausible Deniability hardening

This commit is contained in:
2026-09-09 19:55:34 +02:00
parent ea571d245e
commit b8e4dcb614
7 changed files with 518 additions and 181 deletions
+42 -7
View File
@@ -146,6 +146,7 @@ pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind.
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
@@ -154,16 +155,16 @@ pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer)
.expect("Name encryption");
format!(
"$h${}${}${}",
hex::encode(nonce_bytes),
hex::encode(tag.as_slice()),
hex::encode(&buffer)
)
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(tag.as_slice());
combined.extend_from_slice(&buffer);
hex::encode(combined)
}
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
if parts.len() == 3 {
@@ -189,6 +190,33 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
}
}
}
return None;
}
// Neuer Standard: Reiner Hex-String ohne Präfix (12B Nonce + 16B Tag + Ciphertext)
if stored.len() >= 56 {
if let Ok(bytes) = hex::decode(stored) {
if bytes.len() >= 28 {
let nonce = &bytes[0..12];
let tag = &bytes[12..28];
let ct = &bytes[28..];
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
let mut buffer = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer).ok();
}
}
}
}
}
None
}
@@ -482,12 +510,19 @@ mod tests {
let dek = generate_dek();
let filename = "ultra_geheimes_dokument.pdf";
let encrypted = encrypt_node_name(&dek, filename);
assert!(encrypted.starts_with("$h$"));
// Kein verräterisches Präfix mehr! Reines Hex.
assert!(!encrypted.starts_with("$h$"));
assert!(!encrypted.contains(filename));
assert!(encrypted.len() >= 56);
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek();
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
+14 -43
View File
@@ -5,8 +5,8 @@ use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek,
wrap_dek, KdfParams, FORMAT_VERSION,
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek,
KdfParams, FORMAT_VERSION,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
@@ -425,23 +425,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
let mut found = None;
for slot in &meta.slots {
if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(
&old_kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
found = Some((d, slot.slot_id));
break;
}
}
}
found.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
let (d, _ver, slot_id) = meta
.authenticate(&old_password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
(d, slot_id)
};
println!();
@@ -592,17 +580,9 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let mut found = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some((dek, slot.slot_id));
break;
}
}
}
let (dek, slot_id) = found.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let (dek, _ver, slot_id) = meta
.authenticate(&password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
@@ -631,19 +611,10 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
let mut found = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
found = Some(d);
break;
}
}
}
match found {
Some(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
match meta.authenticate(&password) {
Some((d, _ver, slot_id)) => {
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""), ui::cyan(vault_desc));
Some(d)
}
None => {
+5 -24
View File
@@ -14,7 +14,7 @@ use tokio::net::TcpListener;
use tokio::sync::watch;
use tracing::{debug, warn};
use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek};
use crate::crypto::mnemonic_to_dek;
use crate::storage::Database;
use crate::ui;
use crate::vfs::SanctumFs;
@@ -109,24 +109,8 @@ pub async fn mount_container(
let (dek, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
let mut unwrapped = None;
for slot in &meta.slots {
if let Ok(kek) = derive_kek(password, &slot.kdf_salt, &slot.kdf_params) {
if let Ok(dek) = unwrap_dek(
&kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
) {
unwrapped = Some((dek, slot.version, slot.slot_id));
break;
}
}
}
match unwrapped {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
match meta.authenticate(password) {
Some((dek, ver, slot_id)) => {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
(dek, ver, slot_id)
@@ -142,15 +126,12 @@ pub async fn mount_container(
.context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
let vault_id = if db.has_hidden_vault().unwrap_or(false) {
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty()
};
if is_hidden { 1 } else { 0 }
} else {
0
};
let vault_id = if is_hidden { 1 } else { 0 };
(dek, meta.version, vault_id)
}
};
+94 -12
View File
@@ -13,6 +13,18 @@ use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für einen gesicherten Header-Slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotBackup {
pub slot_id: u32,
pub version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
}
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup {
@@ -24,6 +36,8 @@ pub struct HeaderBackup {
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
#[serde(default)]
pub slots: Vec<SlotBackup>,
pub created_at: u64,
}
@@ -34,6 +48,20 @@ impl HeaderBackup {
.map(|d| d.as_secs())
.unwrap_or(0);
let slots: Vec<SlotBackup> = meta
.slots
.iter()
.map(|s| SlotBackup {
slot_id: s.slot_id,
version: s.version,
kdf_salt_hex: hex::encode(s.kdf_salt),
kdf_params: s.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
header_nonce_hex: hex::encode(s.header_nonce),
header_tag_hex: hex::encode(s.header_tag),
})
.collect();
Self {
magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION,
@@ -43,6 +71,7 @@ impl HeaderBackup {
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag),
slots,
created_at: now,
}
}
@@ -52,6 +81,49 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
}
let mut slots = Vec::new();
if !self.slots.is_empty() {
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
slots.push(SlotMeta {
slot_id: s.slot_id,
version: s.version,
kdf_salt,
kdf_params: s.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
});
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
@@ -88,15 +160,18 @@ impl HeaderBackup {
header_nonce,
header_tag,
};
slots.push(slot0);
}
let slot0 = &slots[0];
Ok(ContainerMeta {
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
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,
})
}
}
@@ -187,6 +262,17 @@ pub fn restore_header_from_recovery_key(
header_tag,
};
// 4. In Container schreiben (bestehenden Slot 1 wie Hidden Vault oder Dummy bewahren)
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let mut slots = vec![slot0];
if let Ok(existing_slots) = db.read_slots() {
if let Some(s1) = existing_slots.into_iter().find(|s| s.slot_id == 1) {
slots.push(s1);
}
}
let meta = ContainerMeta {
version: FORMAT_VERSION,
kdf_salt: salt,
@@ -194,13 +280,9 @@ pub fn restore_header_from_recovery_key(
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
slots,
};
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
+80 -52
View File
@@ -8,9 +8,10 @@ use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{
decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION,
FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams,
FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
};
use zeroize::Zeroizing;
#[allow(dead_code)]
#[derive(Debug, Clone)]
@@ -57,6 +58,28 @@ pub struct ContainerMeta {
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<(Zeroizing<[u8; 32]>, u32, u32)> {
let mut matching = None;
for slot in &self.slots {
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
.ok()
.and_then(|kek| {
unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
});
if let Some(dek) = res {
if matching.is_none() {
matching = Some((dek, slot.version, slot.slot_id));
}
}
}
matching
}
}
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
@@ -97,7 +120,13 @@ impl Database {
self.conn.lock().unwrap()
}
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalten slot_id und vault_id, auto_vacuum) durch.
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<(Zeroizing<[u8; 32]>, u32, u32)>> {
let meta = self.read_meta()?;
Ok(meta.authenticate(password))
}
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
pub fn ensure_schema_upgrades(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
@@ -106,16 +135,9 @@ impl Database {
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
}
// Spalte slot_id in meta
// 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 vault_id in nodes
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_vault_parent ON nodes(vault_id, parent_id)", []);
// Spalte vault_id in chunks
let _ = conn.execute("ALTER TABLE chunks ADD COLUMN vault_id INTEGER NOT NULL DEFAULT 0", []);
Ok(())
}
@@ -195,21 +217,11 @@ impl Database {
Ok(())
}
/// Prüft, ob ein Hidden Vault (Wurzelknoten 2 mit vault_id = 1) im Container existiert.
pub fn has_hidden_vault(&self) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let exists: bool = conn
.query_row(
"SELECT 1 FROM nodes WHERE id = 2 AND vault_id = 1 LIMIT 1",
[],
|_| Ok(true),
)
.optional()?
.unwrap_or(false);
Ok(exists)
}
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - Keinerlei Klartext-Kennzeichnungen wie `vault_id` in SQLite
pub fn init_schema_with_hidden(
&self,
salt: &[u8; 16],
@@ -235,7 +247,6 @@ impl Database {
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_id INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
@@ -244,11 +255,10 @@ impl Database {
modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(vault_id, COALESCE(parent_id, 0), name);
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,
vault_id INTEGER NOT NULL DEFAULT 0,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
@@ -274,11 +284,16 @@ impl Database {
],
)?;
// Wurzelknoten '/' für Vault 0 (id = 1, vault_id = 0) anlegen
// Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
let now = current_timestamp();
conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)",
"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],
)?;
@@ -298,13 +313,6 @@ impl Database {
h_tag.as_slice(),
],
)?;
// Wurzelknoten '/' für Hidden Vault (id = 2, vault_id = 1) anlegen
conn.execute(
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, 1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
} else {
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
@@ -509,7 +517,7 @@ impl Database {
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE vault_id = 0 AND parent_id = ?1 AND name = ?2",
WHERE parent_id = ?1 AND name = ?2",
)?;
let record: Option<NodeRecord> = stmt
@@ -541,7 +549,7 @@ impl Database {
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE vault_id = 1 AND parent_id = ?1",
WHERE parent_id = ?1",
)?;
let rows = stmt.query_map(params![current_id], |row| {
@@ -604,11 +612,11 @@ impl Database {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes WHERE id = ?1 AND vault_id = ?2",
FROM nodes WHERE id = ?1",
)?;
let record = stmt
.query_row(params![id, vault_id], |row| {
.query_row(params![id], |row| {
let enc_name: String = row.get(2)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
@@ -646,11 +654,11 @@ impl Database {
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE vault_id = ?1 AND parent_id = ?2
WHERE parent_id = ?1
ORDER BY is_dir DESC, id ASC",
)?;
let rows = stmt.query_map(params![vault_id, parent_id], |row| {
let rows = stmt.query_map(params![parent_id], |row| {
let enc_name: String = row.get(2)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
@@ -700,9 +708,9 @@ impl Database {
};
conn.execute(
"INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)",
params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
"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();
@@ -927,8 +935,12 @@ impl Database {
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)
@@ -960,8 +972,10 @@ impl Database {
meta.header_tag.as_slice(),
],
)?;
}
// Dummy-Slot 1 für Plausible Deniability generieren
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
if !has_slot1 {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
@@ -1234,8 +1248,6 @@ mod tests {
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
).unwrap();
assert!(db.has_hidden_vault().unwrap());
// Slots prüfen
let slots = db.read_slots().unwrap();
assert_eq!(slots.len(), 2);
@@ -1268,7 +1280,7 @@ mod tests {
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-Tabelle `nodes`
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
let conn = db.conn.lock().unwrap();
let raw_name_v0: String = conn.query_row(
"SELECT name FROM nodes WHERE id = ?1",
@@ -1282,7 +1294,23 @@ mod tests {
params![hidden_file.id],
|r| r.get(0),
).unwrap();
assert!(raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname muss verschlüsselt mit $h$ beginnen");
// Plausible Deniability: Kein $h$-Präfix, kein Klartext
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
// Keine Spalte `vault_id` in nodes oder chunks
let has_vault_id_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
let has_vault_id_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
}
}
+63 -5
View File
@@ -110,7 +110,7 @@ pub fn verify_container(
node_map.insert(node.id, node.clone());
}
// Root-Knoten prüfen (id = 1)
// Root-Knoten prüfen (id = 1 und optional id = 2 für Hidden Vault)
match node_map.get(&1) {
Some(root) => {
if !root.is_dir {
@@ -125,9 +125,18 @@ pub fn verify_container(
}
}
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes {
if node.id == 1 {
if node.id == 1 || node.id == 2 {
continue;
}
@@ -174,6 +183,54 @@ pub fn verify_container(
}
}
// Ermittle die Abstammung aller Knoten zu Root 1 (Vault 0) bzw. Root 2 (Vault 1)
let mut vault0_nodes = HashSet::new();
let mut vault1_nodes = HashSet::new();
vault0_nodes.insert(1i64);
if node_map.contains_key(&2) {
vault1_nodes.insert(2i64);
}
let mut changed = true;
while changed {
changed = false;
for node in &all_nodes {
if let Some(pid) = node.parent_id {
if vault0_nodes.contains(&pid) && !vault0_nodes.contains(&node.id) {
vault0_nodes.insert(node.id);
changed = true;
} else if vault1_nodes.contains(&pid) && !vault1_nodes.contains(&node.id) {
vault1_nodes.insert(node.id);
changed = true;
}
}
}
}
// Falls ein DEK übergeben wurde: Bestimme, zu welchem Vault er gehört
let active_vault_nodes = if let Some(active_dek) = dek {
let is_vault1 = {
let mut found_v1 = false;
for node in &all_nodes {
if node.parent_id == Some(2) {
if crate::crypto::decrypt_node_name(active_dek, &node.name).is_some() {
found_v1 = true;
break;
}
}
}
found_v1
};
if is_vault1 {
Some(&vault1_nodes)
} else {
Some(&vault0_nodes)
}
} else {
None
};
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?;
@@ -187,8 +244,9 @@ pub fn verify_container(
));
}
if let Some(active_dek) = dek {
if full_chunks {
if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
// Nur Chunks verifizieren, die zum verifizierten Tresor gehören (kein Falschalarm für Hidden Vault)
if target_nodes.contains(&node_id) && full_chunks {
match db.read_chunk(node_id, chunk_index) {
Ok(Some(record)) => {
match decrypt_chunk(
@@ -283,7 +341,7 @@ mod tests {
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 2); // Root + photos
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0);
+182
View File
@@ -792,6 +792,188 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards() {
let temp_dir = std::env::temp_dir();
let path_standard: PathBuf = temp_dir.join(format!("test_denial_std_{}.sanctum", std::process::id()));
let path_dual: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_denial_dual_{}.sanctum.hdr", std::process::id()));
if path_standard.exists() {
let _ = std::fs::remove_file(&path_standard);
}
if path_dual.exists() {
let _ = std::fs::remove_file(&path_dual);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let pass_decoy = "OuterDecoyPassphrase2026!";
let pass_hidden = "InnerHiddenVaultPass2026!";
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
// 1. Erstelle Standard-Container (Container A)
let salt_std = generate_salt();
let kek_std = derive_kek(pass_decoy, &salt_std, &kdf_params).unwrap();
let dek_std = generate_dek();
let (wrapped_std, nonce_std, tag_std) = wrap_dek(&kek_std, &dek_std).unwrap();
let db_std = Database::open(&path_standard).unwrap();
db_std.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std).unwrap();
db_std.checkpoint().unwrap();
// 2. Erstelle Dual-Vault Container (Container B)
let salt_b0 = generate_salt();
let salt_b1 = generate_salt();
let kek_b0 = derive_kek(pass_decoy, &salt_b0, &kdf_params).unwrap();
let kek_b1 = derive_kek(pass_hidden, &salt_b1, &kdf_params).unwrap();
let dek_b0 = generate_dek();
let dek_b1 = generate_dek();
let (wrapped_b0, nonce_b0, tag_b0) = wrap_dek(&kek_b0, &dek_b0).unwrap();
let (wrapped_b1, nonce_b1, tag_b1) = wrap_dek(&kek_b1, &dek_b1).unwrap();
let db_dual = Database::open(&path_dual).unwrap();
db_dual.init_schema_with_hidden(
&salt_b0, &kdf_params, &wrapped_b0, &nonce_b0, &tag_b0,
Some((&salt_b1, &kdf_params, &wrapped_b1, &nonce_b1, &tag_b1)),
).unwrap();
db_dual.checkpoint().unwrap();
// 3. FORENSISCHER VERGLEICH: Schema- und Struktur-Ununterscheidbarkeit
let conn_std = rusqlite::Connection::open(&path_standard).unwrap();
let conn_dual = rusqlite::Connection::open(&path_dual).unwrap();
// A) Keine `vault_id` Spalte in nodes oder chunks in beiden Containern
for (name, conn) in [("Standard", &conn_std), ("Dual", &conn_dual)] {
let v_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_nodes, 0, "{} Container darf keine vault_id in nodes haben", name);
let v_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'", [], |r| r.get(0)
).unwrap();
assert_eq!(v_chunks, 0, "{} Container darf keine vault_id in chunks haben", name);
// Genau 2 Slots in meta
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0)).unwrap();
assert_eq!(slot_count, 2, "{} Container muss exakt 2 Slots haben", name);
// Genau 2 Root-Nodes in nodes (id=1 und id=2)
let root_count: i64 = conn.query_row("SELECT count(*) FROM nodes WHERE parent_id IS NULL", [], |r| r.get(0)).unwrap();
assert_eq!(root_count, 2, "{} Container muss exakt 2 Root-Nodes (id=1, id=2) haben", name);
}
drop(conn_std);
drop(conn_dual);
// 4. Dateien in beiden Vaults von Container B anlegen
let fs_decoy = SanctumFs::with_vault(db_dual.clone(), dek_b0.clone(), FORMAT_VERSION, true, 0);
let fs_hidden = SanctumFs::with_vault(db_dual.clone(), dek_b1.clone(), FORMAT_VERSION, true, 1);
let decoy_path = DavPath::new("/family_recipe.txt").unwrap();
let hidden_path = DavPath::new("/private_journal.docx").unwrap();
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f_d = fs_decoy.open(&decoy_path, opts_w.clone()).await.unwrap();
f_d.write_bytes(Bytes::from_static(b"Flour, Sugar, Eggs, Milk")).await.unwrap();
f_d.flush().await.unwrap();
drop(f_d);
let mut f_h = fs_hidden.open(&hidden_path, opts_w).await.unwrap();
f_h.write_bytes(Bytes::from_static(b"My deepest personal thoughts and secrets.")).await.unwrap();
f_h.flush().await.unwrap();
drop(f_h);
db_dual.checkpoint().unwrap();
// 5. FORENSISCHE DATEINAMEN-PRÜFUNG: Kein $h$-Präfix in SQLite
let conn_dual2 = rusqlite::Connection::open(&path_dual).unwrap();
let hidden_node_name: String = conn_dual2.query_row(
"SELECT name FROM nodes WHERE parent_id = 2 LIMIT 1", [], |r| r.get(0)
).unwrap();
assert!(!hidden_node_name.starts_with("$h$"), "Hidden Dateiname darf keinesfalls mit $h$ beginnen!");
assert!(!hidden_node_name.contains("journal"), "Klartext darf keinesfalls in SQLite auftauchen!");
assert!(hidden_node_name.len() >= 56, "Verschlüsselter Name muss ein gültiger Hex-String sein");
let count_dollar_h: i64 = conn_dual2.query_row(
"SELECT count(*) FROM nodes WHERE name LIKE '$h$%'", [], |r| r.get(0)
).unwrap();
assert_eq!(count_dollar_h, 0, "Es darf kein einziger Knoten mit $h$ existieren");
drop(conn_dual2);
// 6. KONSTANTE MULTI-SLOT AUTHENTIFIZIERUNG
let meta_dual = db_dual.read_meta().unwrap();
let auth_decoy = meta_dual.authenticate(pass_decoy).expect("Auth decoy");
assert_eq!(auth_decoy.2, 0, "Decoy Passwort muss Slot 0 entsperren");
assert_eq!(*auth_decoy.0, *dek_b0);
let auth_hidden = meta_dual.authenticate(pass_hidden).expect("Auth hidden");
assert_eq!(auth_hidden.2, 1, "Hidden Passwort muss Slot 1 entsperren");
assert_eq!(*auth_hidden.0, *dek_b1);
assert!(meta_dual.authenticate("WrongPassword123!").is_none());
// 7. INTEGRITÄTSPRÜFUNG (VERIFY) OHNE FALSCHALARME FÜR DUAL-VAULT
// Decoy-Prüfung: Root 2 und Hidden-Chunks dürfen NICHT als verwaist/korrupt gemeldet werden!
let report_decoy = verify_container(&path_dual, Some(&dek_b0), true).unwrap();
assert!(report_decoy.is_healthy(), "Decoy verify muss gesund sein! Fehler: {:?}", report_decoy.errors);
assert_eq!(report_decoy.corrupted_chunks, 0, "Decoy verify darf keine korrupten Chunks melden");
assert_eq!(report_decoy.orphan_nodes, 0, "Decoy verify darf Root 2 nicht als verwaist melden");
// Hidden-Prüfung: Ebenfalls gesund!
let report_hidden = verify_container(&path_dual, Some(&dek_b1), true).unwrap();
assert!(report_hidden.is_healthy(), "Hidden verify muss gesund sein! Fehler: {:?}", report_hidden.errors);
assert_eq!(report_hidden.corrupted_chunks, 0);
assert_eq!(report_hidden.orphan_nodes, 0);
// 8. HEADER BACKUP & RESTORE: Slot 1 (Hidden Vault) bleibt erhalten!
export_header_backup(&path_dual, &backup_path).unwrap();
// Zerstöre Header
let conn_wipe = rusqlite::Connection::open(&path_dual).unwrap();
conn_wipe.execute("DELETE FROM meta", []).unwrap();
drop(conn_wipe);
// Stelle wieder her
restore_header_backup(&path_dual, &backup_path).unwrap();
// Prüfe: Beide Slots funktionieren nach Restore weiterhin tadellos!
let restored_meta = db_dual.read_meta().unwrap();
let res_decoy = restored_meta.authenticate(pass_decoy).expect("Decoy after backup restore");
assert_eq!(res_decoy.2, 0);
let res_hidden = restored_meta.authenticate(pass_hidden).expect("Hidden after backup restore");
assert_eq!(res_hidden.2, 1);
// 9. RECOVERY KEY RESTORE: Slot 1 (Hidden Vault) wird bei Slot 0 Passwort-Rettung NICHT zerstört!
let recovery_phrase_decoy = dek_to_mnemonic(&dek_b0).unwrap();
let brand_new_decoy_pass = "BrandNewDecoyPassword2026!";
restore_header_from_recovery_key(&path_dual, &recovery_phrase_decoy, brand_new_decoy_pass).unwrap();
let rescued_meta = db_dual.read_meta().unwrap();
// Neues Decoy Passwort entsperrt Slot 0
let rescued_decoy = rescued_meta.authenticate(brand_new_decoy_pass).expect("New decoy pass");
assert_eq!(rescued_decoy.2, 0);
assert_eq!(*rescued_decoy.0, *dek_b0);
// Altes Hidden Passwort entsperrt WEITERHIN Slot 1 (wurde nicht zerstört!)
let rescued_hidden = rescued_meta.authenticate(pass_hidden).expect("Hidden pass preserved");
assert_eq!(rescued_hidden.2, 1);
assert_eq!(*rescued_hidden.0, *dek_b1);
// Aufräumen
let _ = std::fs::remove_file(&path_standard);
let _ = std::fs::remove_file(&path_dual);
let _ = std::fs::remove_file(&backup_path);
}