feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Build & Release (Windows x86_64) (push) Canceled after 0s
This commit is contained in:
+199
-16
@@ -9,7 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::crypto::{
|
||||
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
||||
KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
|
||||
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
|
||||
MAGIC_BYTES,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -177,11 +178,79 @@ impl Database {
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
// S-04: Untrusted Container & Fremddatei-Schutz.
|
||||
// Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden:
|
||||
// Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen.
|
||||
let table_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if table_count > 0 {
|
||||
let has_sanctum_schema: bool = {
|
||||
let has_meta: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
if !has_meta {
|
||||
false
|
||||
} else {
|
||||
let magic_opt: Option<Option<Vec<u8>>> = conn
|
||||
.query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0))
|
||||
.optional()?;
|
||||
match magic_opt {
|
||||
Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(),
|
||||
Some(None) => false,
|
||||
None => {
|
||||
// Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung).
|
||||
// Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist,
|
||||
// um Fremddatenbanken weiterhin strikt abzuweisen.
|
||||
let has_nodes: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
let has_chunks: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
has_nodes && has_chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !has_sanctum_schema {
|
||||
bail!(
|
||||
"Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.",
|
||||
path_ref.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
db.init_pragmas()?;
|
||||
db.ensure_schema_upgrades()?;
|
||||
if table_count > 0 {
|
||||
db.ensure_schema_upgrades()?;
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -204,10 +273,16 @@ impl Database {
|
||||
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
|
||||
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
|
||||
let meta = self.read_meta()?;
|
||||
Ok(meta.authenticate(password))
|
||||
let res = meta.authenticate(password);
|
||||
if let Some(ref keys) = res {
|
||||
if let Some(cid) = keys.carrier_node_id() {
|
||||
let _ = self.mark_carrier_node_id(cid);
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
|
||||
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch.
|
||||
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
||||
@@ -219,6 +294,9 @@ impl Database {
|
||||
// Spalte slot_id in meta (falls aus v1 migriert)
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
||||
|
||||
// Spalte is_carrier in nodes (S-03 Carrier-Schutz)
|
||||
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -271,16 +349,23 @@ impl Database {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
|
||||
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
|
||||
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
||||
"SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1",
|
||||
)?;
|
||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Markiert einen existierenden Knoten explizit als Carrier.
|
||||
pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
|
||||
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
|
||||
if node_id == ancestor_id {
|
||||
@@ -374,6 +459,7 @@ impl Database {
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||
@@ -430,10 +516,10 @@ impl Database {
|
||||
dek_1,
|
||||
)) = carrier_config
|
||||
{
|
||||
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
|
||||
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen (is_carrier = 1)
|
||||
conn.execute(
|
||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
|
||||
VALUES (1, ?1, 0, ?2, ?3, ?4)",
|
||||
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
|
||||
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
|
||||
params![c_name, c_size as i64, now, now],
|
||||
)?;
|
||||
let c_id = conn.last_insert_rowid();
|
||||
@@ -589,6 +675,7 @@ impl Database {
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
is_carrier INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(parent_id, name) WHERE parent_id IS NOT NULL;
|
||||
@@ -701,19 +788,88 @@ impl Database {
|
||||
let tag_vec: Vec<u8> = row.get(6)?;
|
||||
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
if salt_vec.len() == 16 {
|
||||
kdf_salt.copy_from_slice(&salt_vec);
|
||||
if salt_vec.len() != 16 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
kdf_salt.copy_from_slice(&salt_vec);
|
||||
|
||||
let mut header_nonce = [0u8; 12];
|
||||
if nonce_vec.len() == 12 {
|
||||
header_nonce.copy_from_slice(&nonce_vec);
|
||||
if nonce_vec.len() != 12 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
5,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
header_nonce.copy_from_slice(&nonce_vec);
|
||||
|
||||
let mut header_tag = [0u8; 16];
|
||||
if tag_vec.len() == 16 {
|
||||
header_tag.copy_from_slice(&tag_vec);
|
||||
if tag_vec.len() != 16 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
6,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
header_tag.copy_from_slice(&tag_vec);
|
||||
|
||||
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
|
||||
if slot_id == 0 {
|
||||
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
4,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
} else if slot_id == 1 {
|
||||
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
4,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).unwrap_or_default();
|
||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id),
|
||||
)),
|
||||
))?;
|
||||
|
||||
validate_kdf_params(&kdf_params)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id),
|
||||
)),
|
||||
))?;
|
||||
|
||||
Ok(SlotMeta {
|
||||
slot_id,
|
||||
@@ -1083,8 +1239,32 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen
|
||||
/// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig
|
||||
/// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03).
|
||||
pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> {
|
||||
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||
if node_id == carrier_id {
|
||||
bail!(
|
||||
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).",
|
||||
node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
||||
self.assert_not_carrier(id)?;
|
||||
|
||||
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
||||
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
||||
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
||||
let _ = self.shred_chunks_for_node(id);
|
||||
|
||||
@@ -1118,6 +1298,7 @@ impl Database {
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
self.assert_not_carrier(id)?;
|
||||
let now = current_timestamp();
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let stored_name = if vault_id == 1 {
|
||||
@@ -1212,6 +1393,7 @@ impl Database {
|
||||
new_size: u64,
|
||||
modified_at: u64,
|
||||
) -> Result<()> {
|
||||
self.assert_not_carrier(node_id)?;
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
@@ -1240,6 +1422,7 @@ impl Database {
|
||||
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
|
||||
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
|
||||
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
|
||||
self.assert_not_carrier(node_id)?;
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user