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::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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user