fix(recovery): resolve metadata MAC desync after recovery (R-NEW-1) & complete release workflow (CI-01)
- R-NEW-1: Recalculate metadata HMAC upon recovery-key restore and mark backup restores as PendingRebuild to rebuild transparently on first mount - CI-01: Update release.yaml to compile both Windows x86_64 and Linux musl with pinned Zig 0.16.0 and cargo-zigbuild 0.23.4 - W-1: Implement statvfs quota determination on Unix via libc - Add RELEASE_PROCESS.md documenting release architecture and steps - Bump version to 0.9.2 across manifests, lockfile, docs, Scoop and WinGet
This commit is contained in:
@@ -391,3 +391,292 @@ async fn test_mount_rejects_tampered_metadata_mac() {
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mount_succeeds_and_rebuilds_mac_after_header_file_restore() {
|
||||
use sanctum::recovery::{export_header_backup, restore_header_backup};
|
||||
use sanctum::storage::MetadataMacStatus;
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let id = std::process::id();
|
||||
let container_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum", id));
|
||||
let backup_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum.hdr", id));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
if backup_path.exists() {
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
let password = "RestoreHeaderPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
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();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "important_doc.pdf", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// 1. Header-Backup exportieren
|
||||
export_header_backup(&container_path, &backup_path).expect("Export header");
|
||||
|
||||
// 2. Header mutwillig zerstören
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
}
|
||||
|
||||
// 3. Header aus Backup wiederherstellen
|
||||
restore_header_backup(&container_path, &backup_path).expect("Restore header");
|
||||
|
||||
// 4. Status vor dem Mount prüfen: muss PendingRebuild sein
|
||||
{
|
||||
let check_db = Database::open(&container_path).unwrap();
|
||||
let status = check_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::PendingRebuild,
|
||||
"Nach restore_header_backup muss Status PendingRebuild sein"
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Echter Mount-Aufruf muss gelingen und den MAC transparent neu aufbauen
|
||||
let auth =
|
||||
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
|
||||
let c_path = container_path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
sanctum::mount::mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(19482),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// Kurz warten, bis mount_container die Vorabprüfung & den MAC-Rebuild vollzogen hat
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
// 6. Nach dem Mount: MAC muss nun Valid und persistent gespeichert sein!
|
||||
let verified_db = Database::open(&container_path).unwrap();
|
||||
let new_status = verified_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
new_status,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach Mount muss der MAC erfolgreich neu aufgebaut und Valid sein"
|
||||
);
|
||||
assert!(verified_db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mount_succeeds_after_recovery_key_restore_slot0() {
|
||||
use sanctum::crypto::dek_to_mnemonic;
|
||||
use sanctum::recovery::restore_header_from_recovery_key;
|
||||
use sanctum::storage::MetadataMacStatus;
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let id = std::process::id();
|
||||
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot0_{}.sanctum", id));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let old_password = "ForgottenOldPassword2026!";
|
||||
let new_password = "RescuedNewPassword2026!";
|
||||
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let phrase = dek_to_mnemonic(&dek).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "my_secrets.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Header zerstören
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
}
|
||||
|
||||
// Mit 24-Wort Notfallschlüssel und neuem Passwort wiederherstellen
|
||||
restore_header_from_recovery_key(&container_path, &phrase, new_password)
|
||||
.expect("Restore from recovery key");
|
||||
|
||||
// R-NEW-1: Unmittelbar nach restore_from_recovery_key MUSS der MAC Valid sein!
|
||||
let restored_db = Database::open(&container_path).unwrap();
|
||||
let status = restored_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach restore_from_recovery_key muss MAC sofort Valid sein"
|
||||
);
|
||||
assert!(
|
||||
restored_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
|
||||
"verify_metadata_mac_for_slot muss direkt Ok(true) liefern (R-NEW-1 Fix)"
|
||||
);
|
||||
drop(restored_db);
|
||||
|
||||
// Mount mit neuem Passwort ausführen
|
||||
let auth =
|
||||
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(new_password.to_string()));
|
||||
let c_path = container_path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
sanctum::mount::mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(19483),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mount_succeeds_after_recovery_key_restore_slot1() {
|
||||
use rand::RngCore;
|
||||
use sanctum::crypto::{dek_to_mnemonic, wrap_slot0_payload, wrap_slot1_payload};
|
||||
use sanctum::recovery::restore_slot_from_recovery_key;
|
||||
use sanctum::storage::MetadataMacStatus;
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let id: u64 = rand::rngs::OsRng.next_u64();
|
||||
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot1_{}.sanctum", id));
|
||||
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let pass_decoy = "DecoyPassword2026!";
|
||||
let pass_hidden_old = "HiddenPasswordOld2026!";
|
||||
let pass_hidden_new = "HiddenPasswordNew2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden_old, &salt_1, &kdf_params).unwrap();
|
||||
let dek_1 = generate_dek();
|
||||
let phrase_1 = dek_to_mnemonic(&dek_1).unwrap();
|
||||
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
|
||||
let db = Database::open(&container_path).unwrap();
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
"carrier.dat",
|
||||
10 * 1024 * 1024,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
db.set_active_slot_and_dek(1, dek_1.clone());
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Slot 1 Header zerstören
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&container_path).unwrap();
|
||||
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Slot 1 mit 24-Wort Schlüssel und neuem Passwort wiederherstellen (dek_0 vorhanden)
|
||||
restore_slot_from_recovery_key(&container_path, &phrase_1, pass_hidden_new, 1, Some(&dek_0))
|
||||
.expect("Restore slot 1 from recovery key");
|
||||
|
||||
// R-NEW-1: Unmittelbar nach restore_slot_from_recovery_key MUSS Slot 1 MAC Valid sein!
|
||||
let restored_db = Database::open(&container_path).unwrap();
|
||||
let status_1 = restored_db
|
||||
.verify_metadata_mac_status_for_slot(1, &dek_1)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_1,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach restore_slot_from_recovery_key muss Slot 1 MAC Valid sein"
|
||||
);
|
||||
assert!(
|
||||
restored_db.verify_metadata_mac_for_slot(1, &dek_1).unwrap(),
|
||||
"verify_metadata_mac_for_slot(1) muss direkt Ok(true) liefern (R-NEW-1 Fix)"
|
||||
);
|
||||
drop(restored_db);
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user