402 lines
14 KiB
Rust
402 lines
14 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::path::Path;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
|
|
use crate::storage::Database;
|
|
|
|
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VerificationReport {
|
|
pub container_path: String,
|
|
pub format_version: u32,
|
|
pub sqlite_ok: bool,
|
|
pub sqlite_errors: Vec<String>,
|
|
pub header_ok: bool,
|
|
pub header_error: Option<String>,
|
|
pub total_nodes: usize,
|
|
pub total_dirs: usize,
|
|
pub total_files: usize,
|
|
pub total_chunks: usize,
|
|
pub total_bytes_decrypted: u64,
|
|
pub corrupted_chunks: usize,
|
|
pub orphan_nodes: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
impl VerificationReport {
|
|
pub fn is_healthy(&self) -> bool {
|
|
self.sqlite_ok
|
|
&& self.header_ok
|
|
&& self.corrupted_chunks == 0
|
|
&& self.orphan_nodes == 0
|
|
&& self.errors.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Führt eine detaillierte Integritäts- und Bitrot-Prüfung auf einem Sanctum-Container durch.
|
|
pub fn verify_container(
|
|
container_path: &Path,
|
|
dek: Option<&Zeroizing<[u8; 32]>>,
|
|
full_chunks: bool,
|
|
) -> Result<VerificationReport> {
|
|
if !container_path.exists() {
|
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
|
}
|
|
|
|
let db = Database::open(container_path)
|
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
|
|
|
let mut report = VerificationReport {
|
|
container_path: container_path.display().to_string(),
|
|
format_version: 0,
|
|
sqlite_ok: true,
|
|
sqlite_errors: Vec::new(),
|
|
header_ok: true,
|
|
header_error: None,
|
|
total_nodes: 0,
|
|
total_dirs: 0,
|
|
total_files: 0,
|
|
total_chunks: 0,
|
|
total_bytes_decrypted: 0,
|
|
corrupted_chunks: 0,
|
|
orphan_nodes: 0,
|
|
errors: Vec::new(),
|
|
};
|
|
|
|
// 1. SQLite B-Tree & Foreign Key Prüfung
|
|
let sqlite_issues = db.run_sqlite_integrity_check()
|
|
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
|
|
if !sqlite_issues.is_empty() {
|
|
report.sqlite_ok = false;
|
|
report.sqlite_errors = sqlite_issues;
|
|
}
|
|
|
|
// 2. Header & Magic Bytes Prüfung
|
|
let meta = match db.read_meta() {
|
|
Ok(m) => {
|
|
report.format_version = m.version;
|
|
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
|
|
report.header_ok = false;
|
|
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
|
|
}
|
|
if m.kdf_salt.len() != 16 {
|
|
report.header_ok = false;
|
|
report.header_error = Some("Ungültige KDF-Salt-Länge".to_string());
|
|
}
|
|
Some(m)
|
|
}
|
|
Err(e) => {
|
|
report.header_ok = false;
|
|
report.header_error = Some(format!("{e}"));
|
|
None
|
|
}
|
|
};
|
|
|
|
// 3. Node-Hierarchie & Strukturprüfung
|
|
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
|
|
.context("Fehler beim Zählen der Knoten und Chunks")?;
|
|
report.total_dirs = dirs;
|
|
report.total_files = files;
|
|
report.total_chunks = chunks_count;
|
|
|
|
let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?;
|
|
report.total_nodes = all_nodes.len();
|
|
|
|
let mut node_map = HashMap::new();
|
|
for node in &all_nodes {
|
|
node_map.insert(node.id, node.clone());
|
|
}
|
|
|
|
// 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 {
|
|
report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
|
|
}
|
|
if root.parent_id.is_some() {
|
|
report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
|
|
}
|
|
}
|
|
None => {
|
|
report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
|
|
}
|
|
}
|
|
|
|
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 || node.id == 2 {
|
|
continue;
|
|
}
|
|
|
|
match node.parent_id {
|
|
Some(pid) => match node_map.get(&pid) {
|
|
Some(parent) => {
|
|
if !parent.is_dir {
|
|
report.errors.push(format!(
|
|
"Knoten '{}' (id={}) hat einen Parent (id={}), der kein Verzeichnis ist!",
|
|
node.name, node.id, pid
|
|
));
|
|
}
|
|
}
|
|
None => {
|
|
report.orphan_nodes += 1;
|
|
report.errors.push(format!(
|
|
"Verwaister Knoten: '{}' (id={}) verweist auf nicht-existenten Parent id={}",
|
|
node.name, node.id, pid
|
|
));
|
|
}
|
|
},
|
|
None => {
|
|
report.orphan_nodes += 1;
|
|
report.errors.push(format!(
|
|
"Verwaister Knoten ohne Parent: '{}' (id={})",
|
|
node.name, node.id
|
|
));
|
|
}
|
|
}
|
|
|
|
// Zyklenprüfung
|
|
let mut visited = HashSet::new();
|
|
visited.insert(node.id);
|
|
let mut curr_parent = node.parent_id;
|
|
while let Some(pid) = curr_parent {
|
|
if !visited.insert(pid) {
|
|
report.errors.push(format!(
|
|
"Zyklische Verzeichnisreferenz bei Knoten '{}' (id={}) entdeckt!",
|
|
node.name, node.id
|
|
));
|
|
break;
|
|
}
|
|
curr_parent = node_map.get(&pid).and_then(|n| n.parent_id);
|
|
}
|
|
}
|
|
|
|
// 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, 2, &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")?;
|
|
|
|
let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2);
|
|
|
|
for (node_id, chunk_index) in chunk_headers {
|
|
if !node_map.contains_key(&node_id) {
|
|
report.errors.push(format!(
|
|
"Verwaister Daten-Chunk: Node #{node_id} Chunk #{chunk_index} gehört zu keinem bekannten Inode!"
|
|
));
|
|
}
|
|
|
|
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(
|
|
active_dek,
|
|
node_id,
|
|
chunk_index,
|
|
&record.ciphertext,
|
|
&record.nonce,
|
|
&record.tag,
|
|
format_version,
|
|
) {
|
|
Ok(plaintext) => {
|
|
report.total_bytes_decrypted += plaintext.len() as u64;
|
|
}
|
|
Err(e) => {
|
|
report.corrupted_chunks += 1;
|
|
report.errors.push(format!(
|
|
"AEAD/Integritätsfehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
report.errors.push(format!(
|
|
"Chunk #{chunk_index} für Node #{node_id} in Index gefunden, aber Daten nicht lesbar!"
|
|
));
|
|
}
|
|
Err(e) => {
|
|
report.corrupted_chunks += 1;
|
|
report.errors.push(format!(
|
|
"DB-Lesefehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::crypto::{
|
|
derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
|
};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn test_verify_healthy_container() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let container_path: PathBuf =
|
|
temp_dir.join(format!("test_verify_ok_{}.sanctum", std::process::id()));
|
|
|
|
if container_path.exists() {
|
|
let _ = fs::remove_file(&container_path);
|
|
}
|
|
|
|
let password = "HealthyTestPassword123!";
|
|
let salt = generate_salt();
|
|
let kdf_params = KdfParams {
|
|
memory_cost: 1024,
|
|
time_cost: 1,
|
|
parallelism: 1,
|
|
};
|
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
|
|
let db = Database::open(&container_path).unwrap();
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
|
|
|
// Verzeichnis & Datei anlegen
|
|
let folder = db.create_node(1, "photos", true).unwrap();
|
|
let file = db.create_node(folder.id, "img.jpg", false).unwrap();
|
|
|
|
// 2 Chunks schreiben
|
|
let chunk0_data = b"Sample JPEG data header and pixels";
|
|
let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap();
|
|
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
|
|
|
|
let chunk1_data = b"Additional payload data bytes";
|
|
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
|
|
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
|
|
|
|
db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap();
|
|
db.checkpoint().unwrap();
|
|
|
|
// Verifizieren
|
|
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, 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);
|
|
|
|
let _ = fs::remove_file(&container_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_detects_bitrot() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let container_path: PathBuf =
|
|
temp_dir.join(format!("test_verify_bitrot_{}.sanctum", std::process::id()));
|
|
|
|
if container_path.exists() {
|
|
let _ = fs::remove_file(&container_path);
|
|
}
|
|
|
|
let password = "BitrotTestPassword123!";
|
|
let salt = generate_salt();
|
|
let kdf_params = KdfParams {
|
|
memory_cost: 1024,
|
|
time_cost: 1,
|
|
parallelism: 1,
|
|
};
|
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
|
|
let db = Database::open(&container_path).unwrap();
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
|
|
|
let file = db.create_node(1, "document.pdf", false).unwrap();
|
|
let chunk_data = b"Vital documents that must not be corrupted";
|
|
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
|
|
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
|
|
db.checkpoint().unwrap();
|
|
drop(db);
|
|
|
|
// Bitrot simulieren: Wir flippen 1 Byte im Ciphertext in SQLite direkt
|
|
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
|
let mut corrupted_ct = ct.clone();
|
|
corrupted_ct[4] ^= 0xFF; // Bit-Flip!
|
|
conn.execute(
|
|
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
|
rusqlite::params![corrupted_ct, file.id],
|
|
).unwrap();
|
|
drop(conn);
|
|
|
|
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
|
|
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
|
|
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
|
|
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
|
|
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
|
|
|
|
let _ = fs::remove_file(&container_path);
|
|
}
|
|
}
|