release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions

- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
2026-09-18 23:40:35 +02:00
parent 1cdb30147b
commit fba7f305e3
28 changed files with 3048 additions and 968 deletions
+114 -34
View File
@@ -85,11 +85,31 @@ impl HeaderBackup {
let mut slots = Vec::new();
if !self.slots.is_empty() {
if self.slots.len() > 2 {
bail!(
"Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)",
self.slots.len()
);
}
let mut seen_ids = std::collections::HashSet::new();
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if s.slot_id > 1 {
bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id);
}
if !seen_ids.insert(s.slot_id) {
bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id);
}
crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| {
anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id)
})?;
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());
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);
@@ -97,10 +117,24 @@ impl HeaderBackup {
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
if s.slot_id == 0 {
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len());
}
} else if s.slot_id == 1 {
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32
{
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
}
}
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());
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);
@@ -108,7 +142,10 @@ impl HeaderBackup {
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());
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);
@@ -123,12 +160,19 @@ impl HeaderBackup {
header_tag,
});
}
if !seen_ids.contains(&0) {
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
}
} 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")?;
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());
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);
@@ -139,7 +183,10 @@ impl HeaderBackup {
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());
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);
@@ -147,7 +194,10 @@ impl HeaderBackup {
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());
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);
@@ -180,19 +230,28 @@ impl HeaderBackup {
/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr).
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let backup = HeaderBackup::from_meta(&meta);
let json_data = serde_json::to_string_pretty(&backup)
.context("Fehler beim Serialisieren des Header-Backups")?;
fs::write(backup_path, json_data)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
fs::write(backup_path, json_data).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht schreiben",
backup_path.display()
)
})?;
Ok(())
}
@@ -203,21 +262,25 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
let content = fs::read_to_string(backup_path)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
let content = fs::read_to_string(backup_path).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht lesen",
backup_path.display()
)
})?;
let backup: HeaderBackup = serde_json::from_str(&content)
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
let meta = backup.to_meta()?;
let db = Database::open(container_path)
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
Ok(())
}
@@ -256,14 +319,16 @@ pub fn restore_slot_from_recovery_key(
dek_0: Option<&[u8; 32]>,
) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
validate_password(new_password)?;
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?;
// 2. Neuen KEK mit frischem Salt ableiten
let mut salt = [0u8; 16];
@@ -273,8 +338,7 @@ pub fn restore_slot_from_recovery_key(
let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
@@ -347,7 +411,8 @@ pub fn restore_slot_from_recovery_key(
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
Ok(())
}
@@ -370,8 +435,10 @@ mod tests {
#[test]
fn test_header_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -392,7 +459,8 @@ mod tests {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// 1. Export
@@ -416,8 +484,13 @@ mod tests {
let restored_db = Database::open(&container_path).unwrap();
let meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap restored DEK");
let active_dek = unwrap_dek(
&restored_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
let _ = fs::remove_file(&container_path);
@@ -427,7 +500,8 @@ mod tests {
#[test]
fn test_restore_from_recovery_key() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -450,7 +524,8 @@ mod tests {
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// Header zerstören
@@ -466,8 +541,13 @@ mod tests {
let rescued_db = Database::open(&container_path).unwrap();
let meta = rescued_db.read_meta().expect("Read rescued meta");
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap rescued DEK");
let unwrapped = unwrap_dek(
&new_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
let _ = fs::remove_file(&container_path);