feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s

This commit is contained in:
2026-09-18 19:12:43 +02:00
parent 80a3bd1911
commit 436790abf0
29 changed files with 1553 additions and 277 deletions
+138 -41
View File
@@ -40,12 +40,74 @@ impl Default for KdfParams {
}
}
pub const MIN_MEMORY_COST_KIB: u32 = 19_456;
pub const MAX_MEMORY_COST_KIB: u32 = 262_144;
pub const MIN_TIME_COST: u32 = 2;
pub const MAX_TIME_COST: u32 = 8;
pub const MIN_PARALLELISM: u32 = 1;
pub const MAX_PARALLELISM: u32 = 8;
pub const MIN_PASSWORD_LENGTH: usize = 12;
/// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04).
pub fn validate_kdf_params(params: &KdfParams) -> Result<()> {
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(&params.memory_cost) {
bail!(
"KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)",
params.memory_cost,
MIN_MEMORY_COST_KIB,
MAX_MEMORY_COST_KIB
);
}
if !(MIN_TIME_COST..=MAX_TIME_COST).contains(&params.time_cost) {
bail!(
"KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})",
params.time_cost,
MIN_TIME_COST,
MAX_TIME_COST
);
}
if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(&params.parallelism) {
bail!(
"KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})",
params.parallelism,
MIN_PARALLELISM,
MAX_PARALLELISM
);
}
Ok(())
}
/// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10).
pub fn validate_password(password: &str) -> Result<()> {
if password.chars().count() < MIN_PASSWORD_LENGTH {
bail!(
"Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).",
MIN_PASSWORD_LENGTH,
password.chars().count()
);
}
Ok(())
}
/// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist.
pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
if pass0 == pass1 {
bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!");
} else if pass0.starts_with(pass1) || pass1.starts_with(pass0) {
bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit.");
} else {
Ok(())
}
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek(
password: &str,
salt: &[u8],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
let argon2_params = Params::new(
params.memory_cost,
params.time_cost,
@@ -56,10 +118,13 @@ pub fn derive_kek(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
if let Err(e) = res {
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
}
Ok(kek)
}
@@ -67,6 +132,7 @@ pub fn derive_kek(
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
OsRng.fill_bytes(&mut *dek);
dek
}
@@ -200,6 +266,17 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
aad
}
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
pub fn set_allow_legacy_names(allow: bool) {
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
}
pub fn allows_legacy_names() -> bool {
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
@@ -207,12 +284,16 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
let cipher = match Aes256Gcm::new_from_slice(dek) {
Ok(c) => c,
Err(_) => return String::new(),
};
let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
.expect("Name encryption");
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
Ok(t) => t,
Err(_) => return String::new(),
};
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(tag.as_slice());
@@ -221,9 +302,9 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
}
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
@@ -250,18 +331,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
// 2. Fallback: Statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
@@ -293,18 +376,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Alte statische AAD für bestehende Container
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
// 2. Fallback: Alte statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
@@ -313,6 +398,10 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
None
}
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names())
}
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
let mnemonic = bip39::Mnemonic::from_entropy(dek)
@@ -545,7 +634,9 @@ pub fn decrypt_chunk(
if payload.len() < 4 {
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
}
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
let mut size_bytes = [0u8; 4];
size_bytes.copy_from_slice(&payload[0..4]);
let uncompressed_size = u32::from_le_bytes(size_bytes) as usize;
if uncompressed_size > CHUNK_SIZE {
bail!(
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
@@ -574,8 +665,8 @@ mod tests {
let password = "SuperSecretMasterPassword123!";
let salt = generate_salt();
let params = KdfParams {
memory_cost: 1024, // Schneller für Unit-Test
time_cost: 1,
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
@@ -787,8 +878,14 @@ mod tests {
hex::encode(static_tag),
hex::encode(&static_buf)
);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
// Standardmäßig muss Legacy-AAD abgewiesen werden (S-07)
assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none());
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
set_allow_legacy_names(true);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
assert_eq!(decrypted_static, filename);
set_allow_legacy_names(false);
// Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek();
@@ -807,7 +904,6 @@ mod tests {
let dek = generate_dek();
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
@@ -819,8 +915,9 @@ mod tests {
#[test]
fn test_model_a_slot_payloads() {
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
let dek_0 = generate_dek();
let dek_1 = generate_dek();
let carrier_node_id = 42i64;