Files
sanctum/src/verify.rs
T

344 lines
12 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)
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());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes {
if node.id == 1 {
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);
}
}
// 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) = dek {
if 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, 2); // Root + 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);
}
}