feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification
This commit is contained in:
+343
@@ -0,0 +1,343 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user