feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification

This commit is contained in:
2026-09-08 09:49:08 +02:00
parent 2d14c64c3e
commit 1c8a860184
11 changed files with 1451 additions and 57 deletions
+129 -2
View File
@@ -9,10 +9,12 @@ use dav_server::{
use futures_util::StreamExt;
use sanctum::{
crypto::{
derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams,
CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
},
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
};
@@ -372,3 +374,128 @@ async fn test_sanctum_v1_backward_compatibility() {
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_disaster_recovery_workflow() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let initial_password = "PrimaryPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
// 1. Container erstellen und Datei schreiben
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/confidential.doc").unwrap();
let secret_payload = b"Top secret corporate documents protected by Sanctum.";
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f = fs.open(&test_file, opts_w).await.expect("create file");
f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write");
f.flush().await.expect("flush");
drop(f);
drop(fs);
db.checkpoint().expect("checkpoint");
// 2. Header-Backup exportieren & 24-Wort Notfallschlüssel sichern
export_header_backup(&container_path, &backup_path).expect("Export header backup");
assert!(backup_path.exists());
let recovery_phrase = dek_to_mnemonic(&dek).expect("Generate 24-word recovery phrase");
assert_eq!(recovery_phrase.split_whitespace().count(), 24);
// 3. Integritätsprüfung vor Katastrophe
let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial");
assert!(initial_report.is_healthy());
assert_eq!(initial_report.total_files, 1);
assert_eq!(initial_report.corrupted_chunks, 0);
// 4. KATASTROPHE 1: Header in SQLite löschen
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Container darf sich ohne Header nicht mehr öffnen lassen
let broken_db = Database::open(&container_path).unwrap();
assert!(broken_db.read_meta().is_err());
drop(broken_db);
// 5. WIEDERHERSTELLUNG 1: Aus externem .sanctum.hdr Backup restoren
restore_header_backup(&container_path, &backup_path).expect("Restore from backup file");
let restored_db = Database::open(&container_path).unwrap();
let restored_meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap();
let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap();
let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version);
let mut opts_r = OpenOptions::default();
opts_r.read = true;
let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored");
let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore");
drop(rf1);
drop(fs1);
drop(restored_db);
// 6. KATASTROPHE 2: Header erneut zerstört UND ursprüngliches Passwort vergessen!
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// 7. WIEDERHERSTELLUNG 2: Via 24-Wort Notfallschlüssel mit BRANDNEUEM Passwort
let brand_new_password = "BrandNewRescuedVaultPassphrase2026!";
restore_header_from_recovery_key(&container_path, &recovery_phrase, brand_new_password)
.expect("Restore from 24-word recovery key");
let rescued_db = Database::open(&container_path).unwrap();
let rescued_meta = rescued_db.read_meta().expect("Read rescued meta");
let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap();
let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap();
let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version);
let mut opts_r2 = OpenOptions::default();
opts_r2.read = true;
let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password");
let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue");
drop(rf2);
drop(fs2);
drop(rescued_db);
// 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert
let conn = rusqlite::Connection::open(&container_path).unwrap();
let mut current_ct: Vec<u8> = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap();
current_ct[0] ^= 0x01; // Bit-Flip
conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap();
drop(conn);
let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot");
assert!(!bitrot_report.is_healthy(), "Container must flag bitrot");
assert_eq!(bitrot_report.corrupted_chunks, 1);
// Aufräumen
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}