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.
|
/// 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.
|
/// 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 {
|
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
|
||||||
let mut nonce_bytes = [0u8; 12];
|
let mut nonce_bytes = [0u8; 12];
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
@@ -154,16 +155,16 @@ pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
|
|||||||
let tag = cipher
|
let tag = cipher
|
||||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer)
|
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), b"SANCTUM_NODE_NAME", &mut buffer)
|
||||||
.expect("Name encryption");
|
.expect("Name encryption");
|
||||||
format!(
|
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||||
"$h${}${}${}",
|
combined.extend_from_slice(&nonce_bytes);
|
||||||
hex::encode(nonce_bytes),
|
combined.extend_from_slice(tag.as_slice());
|
||||||
hex::encode(tag.as_slice()),
|
combined.extend_from_slice(&buffer);
|
||||||
hex::encode(&buffer)
|
hex::encode(combined)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
/// 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> {
|
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$") {
|
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||||
let parts: Vec<&str> = rest.split('$').collect();
|
let parts: Vec<&str> = rest.split('$').collect();
|
||||||
if parts.len() == 3 {
|
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
|
None
|
||||||
}
|
}
|
||||||
@@ -482,12 +510,19 @@ mod tests {
|
|||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
let filename = "ultra_geheimes_dokument.pdf";
|
let filename = "ultra_geheimes_dokument.pdf";
|
||||||
let encrypted = encrypt_node_name(&dek, filename);
|
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.contains(filename));
|
||||||
|
assert!(encrypted.len() >= 56);
|
||||||
|
|
||||||
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
|
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
|
||||||
assert_eq!(decrypted, filename);
|
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
|
// Mit anderem DEK schlägt Entschlüsselung fehl
|
||||||
let other_dek = generate_dek();
|
let other_dek = generate_dek();
|
||||||
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
|
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
|
||||||
|
|||||||
+14
-43
@@ -5,8 +5,8 @@ use clap::{Parser, Subcommand};
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek,
|
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek,
|
||||||
wrap_dek, KdfParams, FORMAT_VERSION,
|
KdfParams, FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
||||||
use sanctum::recovery::{
|
use sanctum::recovery::{
|
||||||
@@ -425,23 +425,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
.read_meta()
|
.read_meta()
|
||||||
.context("Konnte Container-Header nicht lesen")?;
|
.context("Konnte Container-Header nicht lesen")?;
|
||||||
|
|
||||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
|
||||||
let mut found = None;
|
let (d, _ver, slot_id) = meta
|
||||||
for slot in &meta.slots {
|
.authenticate(&old_password)
|
||||||
if let Ok(old_kek) = derive_kek(&old_password, &slot.kdf_salt, &slot.kdf_params) {
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
if let Ok(d) = unwrap_dek(
|
(d, slot_id)
|
||||||
&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."))?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
@@ -592,17 +580,9 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
|||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||||
|
|
||||||
let mut found = None;
|
let (dek, _ver, slot_id) = meta
|
||||||
for slot in &meta.slots {
|
.authenticate(&password)
|
||||||
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
|
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||||
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 phrase = dek_to_mnemonic(&dek)?;
|
let phrase = dek_to_mnemonic(&dek)?;
|
||||||
if slot_id == 1 {
|
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 db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
|
||||||
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
|
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
|
||||||
|
|
||||||
let mut found = None;
|
match meta.authenticate(&password) {
|
||||||
for slot in &meta.slots {
|
Some((d, _ver, slot_id)) => {
|
||||||
if let Ok(kek) = derive_kek(&password, &slot.kdf_salt, &slot.kdf_params) {
|
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
|
||||||
if let Ok(d) = unwrap_dek(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag) {
|
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"), ui::cyan(vault_desc));
|
||||||
found = Some(d);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match found {
|
|
||||||
Some(d) => {
|
|
||||||
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green("✔"));
|
|
||||||
Some(d)
|
Some(d)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
+5
-24
@@ -14,7 +14,7 @@ use tokio::net::TcpListener;
|
|||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, warn};
|
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::storage::Database;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::vfs::SanctumFs;
|
use crate::vfs::SanctumFs;
|
||||||
@@ -109,24 +109,8 @@ pub async fn mount_container(
|
|||||||
|
|
||||||
let (dek, version, vault_id) = match auth {
|
let (dek, version, vault_id) = match auth {
|
||||||
ContainerAuth::Password(ref password) => {
|
ContainerAuth::Password(ref password) => {
|
||||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
||||||
let mut unwrapped = None;
|
match meta.authenticate(password) {
|
||||||
|
|
||||||
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 {
|
|
||||||
Some((dek, ver, slot_id)) => {
|
Some((dek, ver, slot_id)) => {
|
||||||
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
||||||
(dek, ver, slot_id)
|
(dek, ver, slot_id)
|
||||||
@@ -142,15 +126,12 @@ pub async fn mount_container(
|
|||||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
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 is_hidden = {
|
||||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
||||||
!children.is_empty()
|
!children.is_empty()
|
||||||
};
|
};
|
||||||
if is_hidden { 1 } else { 0 }
|
let vault_id = if is_hidden { 1 } else { 0 };
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
(dek, meta.version, vault_id)
|
(dek, meta.version, vault_id)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+94
-12
@@ -13,6 +13,18 @@ use crate::storage::{ContainerMeta, Database, SlotMeta};
|
|||||||
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
|
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
|
||||||
pub const CURRENT_BACKUP_VERSION: u32 = 1;
|
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.
|
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HeaderBackup {
|
pub struct HeaderBackup {
|
||||||
@@ -24,6 +36,8 @@ pub struct HeaderBackup {
|
|||||||
pub wrapped_dek_hex: String,
|
pub wrapped_dek_hex: String,
|
||||||
pub header_nonce_hex: String,
|
pub header_nonce_hex: String,
|
||||||
pub header_tag_hex: String,
|
pub header_tag_hex: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub slots: Vec<SlotBackup>,
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +48,20 @@ impl HeaderBackup {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.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 {
|
Self {
|
||||||
magic: HEADER_BACKUP_MAGIC.to_string(),
|
magic: HEADER_BACKUP_MAGIC.to_string(),
|
||||||
backup_version: CURRENT_BACKUP_VERSION,
|
backup_version: CURRENT_BACKUP_VERSION,
|
||||||
@@ -43,6 +71,7 @@ impl HeaderBackup {
|
|||||||
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
|
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
|
||||||
header_nonce_hex: hex::encode(meta.header_nonce),
|
header_nonce_hex: hex::encode(meta.header_nonce),
|
||||||
header_tag_hex: hex::encode(meta.header_tag),
|
header_tag_hex: hex::encode(meta.header_tag),
|
||||||
|
slots,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +81,49 @@ impl HeaderBackup {
|
|||||||
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
|
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)
|
let salt_bytes = hex::decode(&self.kdf_salt_hex)
|
||||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||||
if salt_bytes.len() != 16 {
|
if salt_bytes.len() != 16 {
|
||||||
@@ -88,15 +160,18 @@ impl HeaderBackup {
|
|||||||
header_nonce,
|
header_nonce,
|
||||||
header_tag,
|
header_tag,
|
||||||
};
|
};
|
||||||
|
slots.push(slot0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot0 = &slots[0];
|
||||||
Ok(ContainerMeta {
|
Ok(ContainerMeta {
|
||||||
version: self.container_format_version,
|
version: slot0.version,
|
||||||
kdf_salt,
|
kdf_salt: slot0.kdf_salt,
|
||||||
kdf_params: self.kdf_params.clone(),
|
kdf_params: slot0.kdf_params.clone(),
|
||||||
wrapped_dek,
|
wrapped_dek: slot0.wrapped_dek.clone(),
|
||||||
header_nonce,
|
header_nonce: slot0.header_nonce,
|
||||||
header_tag,
|
header_tag: slot0.header_tag,
|
||||||
slots: vec![slot0],
|
slots,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +262,17 @@ pub fn restore_header_from_recovery_key(
|
|||||||
header_tag,
|
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 {
|
let meta = ContainerMeta {
|
||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
kdf_salt: salt,
|
kdf_salt: salt,
|
||||||
@@ -194,13 +280,9 @@ pub fn restore_header_from_recovery_key(
|
|||||||
wrapped_dek,
|
wrapped_dek,
|
||||||
header_nonce,
|
header_nonce,
|
||||||
header_tag,
|
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)
|
db.restore_meta(&meta)
|
||||||
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
||||||
|
|
||||||
|
|||||||
+80
-52
@@ -8,9 +8,10 @@ use rand::RngCore;
|
|||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
decrypt_node_name, encrypt_node_name, generate_dummy_slot, KdfParams, FORMAT_VERSION,
|
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams,
|
||||||
FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
|
FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
|
||||||
};
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -57,6 +58,28 @@ pub struct ContainerMeta {
|
|||||||
pub slots: Vec<SlotMeta>,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Arc<Mutex<Connection>>,
|
conn: Arc<Mutex<Connection>>,
|
||||||
@@ -97,7 +120,13 @@ impl Database {
|
|||||||
self.conn.lock().unwrap()
|
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<()> {
|
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
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;");
|
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", []);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,21 +217,11 @@ impl Database {
|
|||||||
Ok(())
|
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).
|
/// 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(
|
pub fn init_schema_with_hidden(
|
||||||
&self,
|
&self,
|
||||||
salt: &[u8; 16],
|
salt: &[u8; 16],
|
||||||
@@ -235,7 +247,6 @@ impl Database {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS nodes (
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
vault_id INTEGER NOT NULL DEFAULT 0,
|
|
||||||
parent_id INTEGER,
|
parent_id INTEGER,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
is_dir INTEGER NOT NULL,
|
is_dir INTEGER NOT NULL,
|
||||||
@@ -244,11 +255,10 @@ impl Database {
|
|||||||
modified_at INTEGER NOT NULL,
|
modified_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
node_id INTEGER NOT NULL,
|
node_id INTEGER NOT NULL,
|
||||||
vault_id INTEGER NOT NULL DEFAULT 0,
|
|
||||||
chunk_index INTEGER NOT NULL,
|
chunk_index INTEGER NOT NULL,
|
||||||
nonce BLOB NOT NULL,
|
nonce BLOB NOT NULL,
|
||||||
tag 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();
|
let now = current_timestamp();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO nodes (id, vault_id, parent_id, name, is_dir, size, created_at, modified_at)
|
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
|
||||||
VALUES (1, 0, NULL, '', 1, 0, ?1, ?2)",
|
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],
|
params![now, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -298,13 +313,6 @@ impl Database {
|
|||||||
h_tag.as_slice(),
|
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 {
|
} else {
|
||||||
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
|
// Fülle Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie
|
||||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
@@ -509,7 +517,7 @@ impl Database {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
||||||
FROM nodes
|
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
|
let record: Option<NodeRecord> = stmt
|
||||||
@@ -541,7 +549,7 @@ impl Database {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
||||||
FROM nodes
|
FROM nodes
|
||||||
WHERE vault_id = 1 AND parent_id = ?1",
|
WHERE parent_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let rows = stmt.query_map(params![current_id], |row| {
|
let rows = stmt.query_map(params![current_id], |row| {
|
||||||
@@ -604,11 +612,11 @@ impl Database {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
"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
|
let record = stmt
|
||||||
.query_row(params![id, vault_id], |row| {
|
.query_row(params![id], |row| {
|
||||||
let enc_name: String = row.get(2)?;
|
let enc_name: String = row.get(2)?;
|
||||||
let name = if vault_id == 1 {
|
let name = if vault_id == 1 {
|
||||||
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
|
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
|
||||||
@@ -646,11 +654,11 @@ impl Database {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
|
||||||
FROM nodes
|
FROM nodes
|
||||||
WHERE vault_id = ?1 AND parent_id = ?2
|
WHERE parent_id = ?1
|
||||||
ORDER BY is_dir DESC, id ASC",
|
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 enc_name: String = row.get(2)?;
|
||||||
let name = if vault_id == 1 {
|
let name = if vault_id == 1 {
|
||||||
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
|
decrypt_node_name(dek, &enc_name).unwrap_or(enc_name)
|
||||||
@@ -700,9 +708,9 @@ impl Database {
|
|||||||
};
|
};
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO nodes (vault_id, parent_id, name, is_dir, size, created_at, modified_at)
|
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)",
|
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
|
||||||
params![vault_id, parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
|
params![parent_id, stored_name, if is_dir { 1 } else { 0 }, now, now],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let new_id = conn.last_insert_rowid();
|
let new_id = conn.last_insert_rowid();
|
||||||
@@ -927,8 +935,12 @@ impl Database {
|
|||||||
|
|
||||||
conn.execute("DELETE FROM meta", [])?;
|
conn.execute("DELETE FROM meta", [])?;
|
||||||
|
|
||||||
|
let mut has_slot1 = false;
|
||||||
if !meta.slots.is_empty() {
|
if !meta.slots.is_empty() {
|
||||||
for slot in &meta.slots {
|
for slot in &meta.slots {
|
||||||
|
if slot.slot_id == 1 {
|
||||||
|
has_slot1 = true;
|
||||||
|
}
|
||||||
let params_json = serde_json::to_string(&slot.kdf_params)?;
|
let params_json = serde_json::to_string(&slot.kdf_params)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
"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(),
|
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_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1234,8 +1248,6 @@ mod tests {
|
|||||||
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
assert!(db.has_hidden_vault().unwrap());
|
|
||||||
|
|
||||||
// Slots prüfen
|
// Slots prüfen
|
||||||
let slots = db.read_slots().unwrap();
|
let slots = db.read_slots().unwrap();
|
||||||
assert_eq!(slots.len(), 2);
|
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();
|
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!");
|
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 conn = db.conn.lock().unwrap();
|
||||||
let raw_name_v0: String = conn.query_row(
|
let raw_name_v0: String = conn.query_row(
|
||||||
"SELECT name FROM nodes WHERE id = ?1",
|
"SELECT name FROM nodes WHERE id = ?1",
|
||||||
@@ -1282,7 +1294,23 @@ mod tests {
|
|||||||
params![hidden_file.id],
|
params![hidden_file.id],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
).unwrap();
|
).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");
|
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());
|
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) {
|
match node_map.get(&1) {
|
||||||
Some(root) => {
|
Some(root) => {
|
||||||
if !root.is_dir {
|
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
|
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
|
||||||
for node in &all_nodes {
|
for node in &all_nodes {
|
||||||
if node.id == 1 {
|
if node.id == 1 || node.id == 2 {
|
||||||
continue;
|
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
|
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
|
||||||
let chunk_headers = db.list_all_chunk_headers()
|
let chunk_headers = db.list_all_chunk_headers()
|
||||||
.context("Fehler beim Abrufen der Chunk-Liste")?;
|
.context("Fehler beim Abrufen der Chunk-Liste")?;
|
||||||
@@ -187,8 +244,9 @@ pub fn verify_container(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(active_dek) = dek {
|
if let (Some(active_dek), Some(target_nodes)) = (dek, active_vault_nodes) {
|
||||||
if full_chunks {
|
// 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) {
|
match db.read_chunk(node_id, chunk_index) {
|
||||||
Ok(Some(record)) => {
|
Ok(Some(record)) => {
|
||||||
match decrypt_chunk(
|
match decrypt_chunk(
|
||||||
@@ -283,7 +341,7 @@ mod tests {
|
|||||||
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
||||||
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
|
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
|
||||||
assert_eq!(report.total_files, 1);
|
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.total_chunks, 2);
|
||||||
assert_eq!(report.corrupted_chunks, 0);
|
assert_eq!(report.corrupted_chunks, 0);
|
||||||
assert_eq!(report.orphan_nodes, 0);
|
assert_eq!(report.orphan_nodes, 0);
|
||||||
|
|||||||
@@ -792,6 +792,188 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
|
|||||||
let _ = std::fs::remove_file(&container_path);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user