feat(security): implement Phase 1 of Plausible Deniability hardening
This commit is contained in:
@@ -792,6 +792,188 @@ async fn test_hidden_vault_and_storage_compaction_integration() {
|
||||
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