fix(storage): ST-01 — fail-closed node name decryption filtering out corrupted entries
This commit is contained in:
+175
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user