feat(security): implement Phase 1 of Plausible Deniability hardening
This commit is contained in:
+80
-52
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user