fix(security): implement P1 loopback session token, P2 AAD filename binding, and P4 memory zeroization

This commit is contained in:
2026-09-09 21:24:07 +02:00
parent 98bfac718f
commit 771c08ff0f
7 changed files with 323 additions and 64 deletions
+92 -13
View File
@@ -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();