feat(security): implement Phase 1 of Plausible Deniability hardening

This commit is contained in:
2026-09-09 19:55:34 +02:00
parent ea571d245e
commit b8e4dcb614
7 changed files with 518 additions and 181 deletions
+129 -47
View File
@@ -13,6 +13,18 @@ use crate::storage::{ContainerMeta, Database, SlotMeta};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für einen gesicherten Header-Slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotBackup {
pub slot_id: u32,
pub version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
}
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup {
@@ -24,6 +36,8 @@ pub struct HeaderBackup {
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
#[serde(default)]
pub slots: Vec<SlotBackup>,
pub created_at: u64,
}
@@ -34,6 +48,20 @@ impl HeaderBackup {
.map(|d| d.as_secs())
.unwrap_or(0);
let slots: Vec<SlotBackup> = meta
.slots
.iter()
.map(|s| SlotBackup {
slot_id: s.slot_id,
version: s.version,
kdf_salt_hex: hex::encode(s.kdf_salt),
kdf_params: s.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&s.wrapped_dek),
header_nonce_hex: hex::encode(s.header_nonce),
header_tag_hex: hex::encode(s.header_tag),
})
.collect();
Self {
magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION,
@@ -43,6 +71,7 @@ impl HeaderBackup {
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag),
slots,
created_at: now,
}
}
@@ -52,51 +81,97 @@ impl HeaderBackup {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
}
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
let mut slots = Vec::new();
if !self.slots.is_empty() {
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
slots.push(SlotMeta {
slot_id: s.slot_id,
version: s.version,
kdf_salt,
kdf_params: s.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
});
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
slots.push(slot0);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
let slot0 = SlotMeta {
slot_id: 0,
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce,
header_tag,
};
let slot0 = &slots[0];
Ok(ContainerMeta {
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
version: slot0.version,
kdf_salt: slot0.kdf_salt,
kdf_params: slot0.kdf_params.clone(),
wrapped_dek: slot0.wrapped_dek.clone(),
header_nonce: slot0.header_nonce,
header_tag: slot0.header_tag,
slots,
})
}
}
@@ -187,6 +262,17 @@ pub fn restore_header_from_recovery_key(
header_tag,
};
// 4. In Container schreiben (bestehenden Slot 1 wie Hidden Vault oder Dummy bewahren)
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let mut slots = vec![slot0];
if let Ok(existing_slots) = db.read_slots() {
if let Some(s1) = existing_slots.into_iter().find(|s| s.slot_id == 1) {
slots.push(s1);
}
}
let meta = ContainerMeta {
version: FORMAT_VERSION,
kdf_salt: salt,
@@ -194,13 +280,9 @@ pub fn restore_header_from_recovery_key(
wrapped_dek,
header_nonce,
header_tag,
slots: vec![slot0],
slots,
};
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;