fix(storage): ST-01 — fail-closed node name decryption filtering out corrupted entries

This commit is contained in:
2026-09-19 23:19:41 +02:00
parent c21d63ad28
commit dd6b6ff94d
+175 -36
View File
@@ -6,6 +6,7 @@ use anyhow::{bail, Result};
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use tracing::warn;
use crate::crypto::{ use crate::crypto::{
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key, compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
@@ -1433,8 +1434,16 @@ impl Database {
let mut matched_record = None; let mut matched_record = None;
for r in rows { for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?; let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name = let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) {
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name); Some(name) => name,
None => {
warn!(
"Knoten {} in Pfadauflösung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
continue;
}
};
if dec_name == *segment { if dec_name == *segment {
matched_record = Some(NodeRecord { matched_record = Some(NodeRecord {
id, id,
@@ -1482,29 +1491,49 @@ impl Database {
FROM nodes WHERE id = ?1", FROM nodes WHERE id = ?1",
)?; )?;
let record = stmt let raw = stmt
.query_row(params![id], |row| { .query_row(params![id], |row| {
let enc_name: String = row.get(2)?; Ok((
let p_id: Option<i64> = row.get(1)?; row.get::<_, i64>(0)?,
let name = if vault_id == 1 { row.get::<_, Option<i64>>(1)?,
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name) row.get::<_, String>(2)?,
row.get::<_, i32>(3)? != 0,
row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
row.get::<_, i64>(6)? as u64,
))
})
.optional()?;
match raw {
Some((id, parent_id, enc_name, is_dir, size, created_at, modified_at)) => {
let name = if vault_id == 1 && parent_id.is_some() {
match decrypt_node_name(dek, parent_id.unwrap_or(0), &enc_name) {
Some(dec) => dec,
None => {
warn!(
"Knoten {} kann nicht geladen werden: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
return Ok(None);
}
}
} else { } else {
enc_name enc_name
}; };
Ok(NodeRecord { Ok(Some(NodeRecord {
id: row.get(0)?, id,
parent_id: row.get(1)?, parent_id,
name, name,
is_dir: row.get::<_, i32>(3)? != 0, is_dir,
size: row.get::<_, i64>(4)? as u64, size,
created_at: row.get::<_, i64>(5)? as u64, created_at,
modified_at: row.get::<_, i64>(6)? as u64, modified_at,
}) }))
}) }
.optional()?; None => Ok(None),
}
Ok(record)
} }
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> { pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
@@ -1527,27 +1556,44 @@ impl Database {
)?; )?;
let rows = stmt.query_map(params![parent_id], |row| { let rows = stmt.query_map(params![parent_id], |row| {
let enc_name: String = row.get(2)?; Ok((
let name = if vault_id == 1 { row.get::<_, i64>(0)?,
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name) row.get::<_, Option<i64>>(1)?,
} else { row.get::<_, String>(2)?,
enc_name row.get::<_, i32>(3)? != 0,
}; row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
Ok(NodeRecord { row.get::<_, i64>(6)? as u64,
id: row.get(0)?, ))
parent_id: row.get(1)?,
name,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})?; })?;
let mut entries = Vec::new(); let mut entries = Vec::new();
for r in rows { for r in rows {
entries.push(r?); let (id, p_id, enc_name, is_dir, size, created_at, modified_at) = r?;
let name = if vault_id == 1 {
match decrypt_node_name(dek, parent_id, &enc_name) {
Some(dec) => dec,
None => {
warn!(
"Knoten {} in Verzeichnisauflistung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
continue;
}
}
} else {
enc_name
};
entries.push(NodeRecord {
id,
parent_id: p_id,
name,
is_dir,
size,
created_at,
modified_at,
});
} }
Ok(entries) Ok(entries)
} }
@@ -2972,4 +3018,97 @@ mod tests {
assert!(read_dek.is_some()); assert!(read_dek.is_some());
assert_eq!(read_dek.unwrap()[0], 42); assert_eq!(read_dek.unwrap()[0], 42);
} }
#[test]
fn test_st01_fail_closed_name_decryption_no_hex_leak() {
let db = Database::open_in_memory().unwrap();
let (salt0, kdf0) = ([1u8; 16], KdfParams::default());
let (salt1, kdf1) = ([2u8; 16], KdfParams::default());
let (dek0, dek1) = ([10u8; 32], [20u8; 32]);
let carrier_name = "backup.iso";
let carrier_size = 5 * 1024 * 1024;
db.init_schema_with_carrier(
&salt0,
&kdf0,
&[0u8; 40],
&[0u8; 12],
&[0u8; 16],
Some((
carrier_name,
carrier_size,
&salt1,
&kdf1,
&[1u8; 40],
&[1u8; 12],
&[1u8; 16],
&dek0,
&dek1,
)),
)
.unwrap();
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().unwrap();
assert_eq!(root1.id, 2);
// 1. Zwei Dateien in Vault 1 anlegen
let node_valid = db
.create_node_in_vault(1, root1.id, "secret_valid.txt", false, &dek1)
.unwrap();
let node_corrupt = db
.create_node_in_vault(1, root1.id, "secret_corrupt.txt", false, &dek1)
.unwrap();
// 2. Vor Korruption: Beide Dateien sichtbar
let children_before = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
assert_eq!(children_before.len(), 2);
// 3. Simuliere Bitrot / Manipulation: Überschreibe den verschlüsselten Namen in der DB
{
let conn = db.conn();
conn.execute(
"UPDATE nodes SET name = 'deadbeef_invalid_ciphertext_without_valid_tag' WHERE id = ?1",
params![node_corrupt.id],
)
.unwrap();
}
// 4. ST-01 Prüfungen (Fail-Closed):
// a) list_children_in_vault filtert den korrupten Knoten heraus
let children_after = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
assert_eq!(
children_after.len(),
1,
"ST-01: Korrupter Knoten muss aus der Liste herausgefiltert werden"
);
assert_eq!(children_after[0].id, node_valid.id);
assert_eq!(children_after[0].name, "secret_valid.txt");
assert!(
!children_after.iter().any(|c| c.name.contains("deadbeef")),
"Kein Hex-Ciphertext-Leak"
);
// b) resolve_path_in_vault gibt None zurück (nicht den Hex-Namen)
let resolved = db
.resolve_path_in_vault("/secret_corrupt.txt", 1, &dek1)
.unwrap();
assert!(
resolved.is_none(),
"ST-01: Pfad mit korruptem Namen darf nicht aufgelöst werden"
);
// c) get_node_by_id_in_vault gibt None zurück (fail-closed statt Hex-String)
let by_id = db
.get_node_by_id_in_vault(node_corrupt.id, 1, &dek1)
.unwrap();
assert!(
by_id.is_none(),
"ST-01: get_node_by_id_in_vault muss None zurückgeben, wenn Name unlesbar ist"
);
// d) Gültiger Knoten kann normal geladen werden
let valid_by_id = db.get_node_by_id_in_vault(node_valid.id, 1, &dek1).unwrap();
assert!(valid_by_id.is_some());
assert_eq!(valid_by_id.unwrap().name, "secret_valid.txt");
}
} }