fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization
This commit is contained in:
+92
-13
@@ -189,16 +189,29 @@ pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
|
||||
(wrapped_dek, nonce, tag, salt)
|
||||
}
|
||||
|
||||
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM.
|
||||
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind.
|
||||
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Dateinamen im Hidden Vault,
|
||||
/// um Directory-Hijacking und Cross-Node Name-Substitution-Angriffe kryptografisch zu verhindern:
|
||||
/// Magic "SANCNAME" (8 Bytes) || parent_id (8 Bytes Little-Endian).
|
||||
#[inline]
|
||||
pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
||||
let mut aad = [0u8; 16];
|
||||
aad[..8].copy_from_slice(b"SANCNAME");
|
||||
aad[8..].copy_from_slice(&parent_id.to_le_bytes());
|
||||
aad
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Verwendet reines Hex-Encoding ohne verräterisches Präfix (12B Nonce + 16B Tag + Ciphertext).
|
||||
pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
|
||||
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 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), b"SANCTUM_NODE_NAME", &mut buffer)
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
|
||||
.expect("Name encryption");
|
||||
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
@@ -208,7 +221,9 @@ pub fn encrypt_node_name(dek: &[u8; 32], name: &str) -> String {
|
||||
}
|
||||
|
||||
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
||||
pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
|
||||
/// 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> {
|
||||
// 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();
|
||||
@@ -220,11 +235,13 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
|
||||
) {
|
||||
if nonce_bytes.len() == 12 && tag_bytes.len() == 16 {
|
||||
let cipher = Aes256Gcm::new_from_slice(dek).ok()?;
|
||||
let mut buffer = ct_bytes;
|
||||
// 1. Primär: Authentifizierung mit parent_id AAD
|
||||
let aad = build_name_aad(parent_id);
|
||||
let mut buffer = ct_bytes.clone();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&aad,
|
||||
&mut buffer,
|
||||
Tag::from_slice(&tag_bytes),
|
||||
)
|
||||
@@ -232,13 +249,27 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Neuer Standard: Reiner Hex-String ohne Präfix (12B Nonce + 16B Tag + Ciphertext)
|
||||
// Reiner Hex-String (12B Nonce + 16B Tag + Ciphertext)
|
||||
if stored.len() >= 56 {
|
||||
if let Ok(bytes) = hex::decode(stored) {
|
||||
if bytes.len() >= 28 {
|
||||
@@ -247,11 +278,13 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
|
||||
let ct = &bytes[28..];
|
||||
|
||||
if let Ok(cipher) = Aes256Gcm::new_from_slice(dek) {
|
||||
// 1. Primär: Authentifizierung mit parent_id AAD
|
||||
let aad = build_name_aad(parent_id);
|
||||
let mut buffer = ct.to_vec();
|
||||
if cipher
|
||||
.decrypt_in_place_detached(
|
||||
Nonce::from_slice(nonce),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&aad,
|
||||
&mut buffer,
|
||||
Tag::from_slice(tag),
|
||||
)
|
||||
@@ -259,6 +292,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], stored: &str) -> Option<String> {
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,23 +617,40 @@ mod tests {
|
||||
fn test_hidden_node_name_encryption_and_dummy_slot() {
|
||||
let dek = generate_dek();
|
||||
let filename = "ultra_geheimes_dokument.pdf";
|
||||
let encrypted = encrypt_node_name(&dek, filename);
|
||||
let parent_id = 2i64;
|
||||
let encrypted = encrypt_node_name(&dek, parent_id, filename);
|
||||
// Kein verräterisches Präfix mehr! Reines Hex.
|
||||
assert!(!encrypted.starts_with("$h$"));
|
||||
assert!(!encrypted.contains(filename));
|
||||
assert!(encrypted.len() >= 56);
|
||||
|
||||
let decrypted = decrypt_node_name(&dek, &encrypted).expect("Decrypt name");
|
||||
let decrypted = decrypt_node_name(&dek, parent_id, &encrypted).expect("Decrypt name");
|
||||
assert_eq!(decrypted, filename);
|
||||
|
||||
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
|
||||
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
|
||||
let decrypted_legacy = decrypt_node_name(&dek, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
assert_eq!(decrypted_legacy, filename);
|
||||
|
||||
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
|
||||
let cipher = Aes256Gcm::new_from_slice(&dek[..]).unwrap();
|
||||
let mut static_buf = filename.as_bytes().to_vec();
|
||||
let static_nonce = [42u8; 12];
|
||||
let static_tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
|
||||
.unwrap();
|
||||
let legacy_static_format = format!(
|
||||
"$h${}${}${}",
|
||||
hex::encode(static_nonce),
|
||||
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");
|
||||
assert_eq!(decrypted_static, filename);
|
||||
|
||||
// Mit anderem DEK schlägt Entschlüsselung fehl
|
||||
let other_dek = generate_dek();
|
||||
assert!(decrypt_node_name(&other_dek, &encrypted).is_none());
|
||||
assert!(decrypt_node_name(&other_dek, parent_id, &encrypted).is_none());
|
||||
|
||||
// Dummy-Slot hat korrekte Längen (72 Bytes für Modell A)
|
||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||
@@ -596,6 +660,21 @@ mod tests {
|
||||
assert_eq!(dummy_salt.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_name_aad_parent_binding() {
|
||||
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");
|
||||
|
||||
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
|
||||
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
Reference in New Issue
Block a user