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

This commit is contained in:
2026-09-18 19:12:43 +02:00
parent 80a3bd1911
commit 436790abf0
29 changed files with 1553 additions and 277 deletions
+199 -16
View File
@@ -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(&params_str).unwrap_or_default();
let kdf_params: KdfParams = serde_json::from_str(&params_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()?;
{