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 => {
+8 -27
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) {
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
// 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()
};
let vault_id = if is_hidden { 1 } else { 0 };
(dek, meta.version, vault_id)
}
};
+129 -47
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,51 +81,97 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
}
let salt_bytes = hex::decode(&self.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 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 {
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(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.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(&self.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);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
slots.push(slot0);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.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(&self.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);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
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);