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