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
+14 -4
View File
@@ -187,17 +187,27 @@ pub async fn mount_container(
// S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren
let _advisory_lock = db.acquire_advisory_lock_guard(false)?;
// K-01: Metadaten-Integrität via HMAC-SHA256 validieren (Format V3)
// K-01 / R-NEW-1: Metadaten-Integrität via HMAC-SHA256 validieren (Format V3)
if version >= crate::crypto::FORMAT_VERSION_V3 {
match db.verify_metadata_mac_for_slot(vault_id, &dek) {
Ok(true) => {
match db.verify_metadata_mac_status_for_slot(vault_id, &dek) {
Ok(crate::storage::MetadataMacStatus::Valid) => {
if !stealth {
println!(
" • Metadaten-MAC: ✔ Integrität erfolgreich verifiziert (HMAC-SHA256)"
);
}
}
Ok(false) => {
Ok(crate::storage::MetadataMacStatus::PendingRebuild) => {
if !stealth {
println!(
" • Metadaten-MAC: Metadaten-MAC nach Wiederherstellung neu aufgebaut"
);
}
db.set_active_slot_and_dek(vault_id, dek.clone());
db.update_metadata_mac()?;
db.checkpoint()?;
}
Ok(crate::storage::MetadataMacStatus::Invalid) => {
bail!(
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01)."
);
+3 -3
View File
@@ -1,7 +1,7 @@
//! Plattform-Abstraktionsschicht für Sanctum.
//!
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
//! Re-Exportiert die plattformspezifischen Implementierungen (Speichersperren,
//! Prozessüberwachung, Session-Lock, Plattenplatzprüfung und Shell-Integration)
//! aus `crate::windows` (welches via `#[cfg]`-Gates Windows-, Linux- und POSIX-Code bereitstellt).
pub use crate::windows::*;
+28
View File
@@ -412,6 +412,11 @@ pub fn restore_slot_from_recovery_key(
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
// R-NEW-1: Metadaten-MAC für den wiederhergestellten Slot unmittelbar berechnen und persistieren
db.set_active_slot_and_dek(target_slot_id, dek.clone());
db.update_metadata_mac()
.context("Fehler beim Aktualisieren des Metadaten-MAC nach Header-Rekonstruktion")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
@@ -561,6 +566,16 @@ mod tests {
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
// R-NEW-1: Nach restore_header_backup muss der MAC-Status PendingRebuild sein
let mac_status = restored_db
.verify_metadata_mac_status_for_slot(0, &active_dek)
.expect("Verify MAC status");
assert_eq!(
mac_status,
crate::storage::MetadataMacStatus::PendingRebuild,
"Nach Header-Restore ohne Plaintext-DEK muss MAC-Status PendingRebuild sein"
);
let _ = fs::remove_file(&container_path);
let _ = fs::remove_file(&backup_path);
}
@@ -618,6 +633,19 @@ mod tests {
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
// R-NEW-1: Nach recovery key restore muss der MAC sofort gültig und persistent sein
let mac_status = rescued_db
.verify_metadata_mac_status_for_slot(0, &unwrapped)
.expect("Verify MAC status");
assert_eq!(
mac_status,
crate::storage::MetadataMacStatus::Valid,
"Nach Recovery-Key-Restore muss MAC sofort Valid sein"
);
assert!(rescued_db
.verify_metadata_mac_for_slot(0, &unwrapped)
.unwrap());
let _ = fs::remove_file(&container_path);
}
}
+47 -17
View File
@@ -208,6 +208,18 @@ fn current_timestamp() -> u64 {
.unwrap_or(0)
}
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataMacStatus {
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten überein.
Valid,
/// Container-Header wurde frisch aus einem Backup restauriert (metadata_gen == 0 && metadata_mac IS NULL).
/// Der MAC muss beim Mounten transparent mit dem aktiven DEK neu aufgebaut werden.
PendingRebuild,
/// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
Invalid,
}
impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
@@ -2316,6 +2328,15 @@ impl Database {
/// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden).
pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result<bool> {
Ok(self.verify_metadata_mac_status_for_slot(slot_id, dek)? == MetadataMacStatus::Valid)
}
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1).
pub fn verify_metadata_mac_status_for_slot(
&self,
slot_id: u32,
dek: &[u8; 32],
) -> Result<MetadataMacStatus> {
let conn = self.conn();
let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn
.query_row(
@@ -2326,19 +2347,24 @@ impl Database {
.optional()?;
let Some((version, mac_opt, gen)) = meta_row else {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
};
if version < FORMAT_VERSION_V3 {
return Ok(true);
return Ok(MetadataMacStatus::Valid);
}
// R-NEW-1: Frisch restaurierter Container (gen == 0 && mac_opt IS NULL)
if gen == 0 && mac_opt.is_none() {
return Ok(MetadataMacStatus::PendingRebuild);
}
let Some(mac_bytes) = mac_opt else {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
};
if mac_bytes.len() != 32 {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
}
let mut expected_mac = [0u8; 32];
@@ -2347,12 +2373,11 @@ impl Database {
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(
&mac_key,
gen,
&canonical,
&expected_mac,
))
if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) {
Ok(MetadataMacStatus::Valid)
} else {
Ok(MetadataMacStatus::Invalid)
}
}
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
@@ -2528,7 +2553,12 @@ impl Database {
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0,
lock_pid INTEGER,
lock_host TEXT,
lock_time INTEGER
);",
)?;
@@ -2542,8 +2572,8 @@ impl Database {
}
let params_json = serde_json::to_string(&slot.kdf_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0)",
params![
slot.slot_id,
MAGIC_BYTES.as_slice(),
@@ -2559,8 +2589,8 @@ impl Database {
} else {
let params_json = serde_json::to_string(&meta.kdf_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
params![
MAGIC_BYTES.as_slice(),
meta.version,
@@ -2578,8 +2608,8 @@ impl Database {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
+26 -1
View File
@@ -890,7 +890,32 @@ pub fn get_available_disk_space<P: AsRef<std::path::Path>>(path: P) -> Option<u6
None
}
}
#[cfg(not(windows))]
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let p = path.as_ref();
let dir = if p.is_dir() {
p.to_path_buf()
} else {
p.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_else(|| p.to_path_buf())
};
let Ok(c_path) = CString::new(dir.as_os_str().as_bytes()) else {
return None;
};
unsafe {
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 {
let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
Some(free)
} else {
None
}
}
}
#[cfg(not(any(windows, unix)))]
{
let _ = path;
Some(1024 * 1024 * 1024 * 1024)