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