fix(recovery): resolve metadata MAC desync after recovery (R-NEW-1) & complete release workflow (CI-01)
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions

- 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:
2026-09-21 08:15:17 +02:00
parent efb28c1c45
commit badbe3bd18
16 changed files with 607 additions and 59 deletions
+289
View File
@@ -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);
}