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
+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");
}
}