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::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use tracing::warn;
use crate::crypto::{
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
@@ -1433,8 +1434,16 @@ impl Database {
let mut matched_record = None;
for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name =
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &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 {
matched_record = Some(NodeRecord {
id,
@@ -1482,29 +1491,49 @@ impl Database {
FROM nodes WHERE id = ?1",
)?;
let record = stmt
let raw = stmt
.query_row(params![id], |row| {
let enc_name: String = row.get(2)?;
let p_id: Option<i64> = row.get(1)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<i64>>(1)?,
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 {
enc_name
};
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
Ok(Some(NodeRecord {
id,
parent_id,
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,
})
})
.optional()?;
Ok(record)
is_dir,
size,
created_at,
modified_at,
}))
}
None => Ok(None),
}
}
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 enc_name: String = row.get(2)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
} else {
enc_name
};
Ok(NodeRecord {
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,
})
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<i64>>(1)?,
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,
))
})?;
let mut entries = Vec::new();
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)
}
@@ -2972,4 +3018,97 @@ mod tests {
assert!(read_dek.is_some());
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");
}
}