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
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
This commit is contained in:
+138
-41
@@ -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(¶ms.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(¶ms.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(¶ms.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;
|
||||
|
||||
Reference in New Issue
Block a user