release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen - SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation) - SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256 - SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI - SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query) - SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq - SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
+352
-194
@@ -9,8 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::crypto::{
|
||||
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
||||
validate_kdf_params, 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;
|
||||
|
||||
@@ -52,11 +52,11 @@ pub struct SlotMeta {
|
||||
/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`).
|
||||
#[derive(Clone)]
|
||||
pub struct UnlockedKeys(
|
||||
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
|
||||
pub u32, // 1: Formatversion
|
||||
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
|
||||
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
|
||||
pub u32, // 1: Formatversion
|
||||
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
|
||||
pub Option<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
|
||||
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
|
||||
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
|
||||
);
|
||||
|
||||
impl UnlockedKeys {
|
||||
@@ -94,12 +94,39 @@ impl ContainerMeta {
|
||||
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
|
||||
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
|
||||
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
|
||||
// SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek)
|
||||
// Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten
|
||||
if self.slots.is_empty() || self.slots.len() > 2 {
|
||||
return None;
|
||||
}
|
||||
if !self.slots.iter().any(|s| s.slot_id == 0) {
|
||||
return None;
|
||||
}
|
||||
if self.slots.iter().any(|s| s.slot_id > 1) {
|
||||
return None;
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for slot in &self.slots {
|
||||
if !seen_ids.insert(slot.slot_id) {
|
||||
return None;
|
||||
}
|
||||
if validate_kdf_params(&slot.kdf_params).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut matching = None;
|
||||
for slot in &self.slots {
|
||||
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
|
||||
.ok()
|
||||
.and_then(|kek| {
|
||||
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
|
||||
unwrap_key_payload(
|
||||
&kek,
|
||||
&slot.wrapped_dek,
|
||||
&slot.header_nonce,
|
||||
&slot.header_tag,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Some(payload) = res {
|
||||
@@ -109,7 +136,11 @@ impl ContainerMeta {
|
||||
let carrier_node_id = if payload.len() >= 40 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
|
||||
if cid > 0 { Some(cid) } else { None }
|
||||
if cid > 0 {
|
||||
Some(cid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if payload.len() >= 32 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
None
|
||||
@@ -135,7 +166,13 @@ impl ContainerMeta {
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
|
||||
matching = Some(UnlockedKeys(
|
||||
dek_1,
|
||||
slot.version,
|
||||
1,
|
||||
carrier_dek,
|
||||
carrier_node_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +279,27 @@ impl Database {
|
||||
path_ref.display()
|
||||
);
|
||||
}
|
||||
|
||||
// SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS)
|
||||
let has_meta_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if has_meta_table {
|
||||
let slot_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM meta;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
@@ -285,17 +343,25 @@ impl Database {
|
||||
/// 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);
|
||||
let av: i64 = conn
|
||||
.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if av != 2 {
|
||||
// Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum
|
||||
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
|
||||
}
|
||||
|
||||
// Spalte slot_id in meta (falls aus v1 migriert)
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
||||
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", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -318,7 +384,9 @@ impl Database {
|
||||
/// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben.
|
||||
pub fn incremental_vacuum(&self, pages: Option<usize>) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let before: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if before <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -337,7 +405,9 @@ impl Database {
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
|
||||
let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let after: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let actual_freed = (before - after).max(0) as usize;
|
||||
Ok(actual_freed.max(stepped))
|
||||
}
|
||||
@@ -352,9 +422,7 @@ impl Database {
|
||||
/// 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 is_carrier = 1 LIMIT 1",
|
||||
)?;
|
||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?;
|
||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -380,16 +448,26 @@ impl Database {
|
||||
)
|
||||
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
|
||||
)?;
|
||||
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
|
||||
let exists: Option<i64> = stmt
|
||||
.query_row(params![ancestor_id, node_id], |r| r.get(0))
|
||||
.optional()?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
||||
///
|
||||
/// SA-07 / Technischer Hinweis zu sicherem Löschen:
|
||||
/// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records
|
||||
/// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor
|
||||
/// logischer Wiederherstellung auf Dateisystem- und Datenbankebene.
|
||||
/// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS)
|
||||
/// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization)
|
||||
/// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren
|
||||
/// neuen Flash-Speicherzellen zuweisen.
|
||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1"
|
||||
)?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
||||
let chunks: Vec<(u32, usize)> = stmt
|
||||
.query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
@@ -426,15 +504,15 @@ impl Database {
|
||||
header_nonce_0: &[u8; 12],
|
||||
header_tag_0: &[u8; 16],
|
||||
carrier_config: Option<(
|
||||
&str, // carrier_name
|
||||
u64, // carrier_size_bytes
|
||||
&[u8; 16], // salt_1
|
||||
&KdfParams, // kdf_params_1
|
||||
&[u8], // wrapped_dek_1 (72B)
|
||||
&[u8; 12], // header_nonce_1
|
||||
&[u8; 16], // header_tag_1
|
||||
&[u8; 32], // raw DEK_0
|
||||
&[u8; 32], // raw DEK_1
|
||||
&str, // carrier_name
|
||||
u64, // carrier_size_bytes
|
||||
&[u8; 16], // salt_1
|
||||
&KdfParams, // kdf_params_1
|
||||
&[u8], // wrapped_dek_1 (72B)
|
||||
&[u8; 12], // header_nonce_1
|
||||
&[u8; 16], // header_tag_1
|
||||
&[u8; 32], // raw DEK_0
|
||||
&[u8; 32], // raw DEK_1
|
||||
)>,
|
||||
) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -547,13 +625,8 @@ impl Database {
|
||||
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
||||
|
||||
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_1,
|
||||
c_id,
|
||||
0,
|
||||
&manifest_bytes,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (inner_ct, inner_nonce, inner_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?;
|
||||
let inner_ct_len = inner_ct.len() as u32;
|
||||
|
||||
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
||||
@@ -568,13 +641,8 @@ impl Database {
|
||||
}
|
||||
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
||||
|
||||
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
0,
|
||||
&outer_plaintext,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (outer_ct, outer_nonce, outer_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
||||
@@ -593,20 +661,9 @@ impl Database {
|
||||
|
||||
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||
for b in 1..total_blocks {
|
||||
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
b,
|
||||
&dummy_noise,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
chunk_stmt.execute(params![
|
||||
c_id,
|
||||
b,
|
||||
nonce.as_slice(),
|
||||
tag.as_slice(),
|
||||
ct,
|
||||
])?;
|
||||
let (ct, nonce, tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?;
|
||||
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
|
||||
|
||||
if b % 500 == 0 {
|
||||
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
|
||||
@@ -767,18 +824,43 @@ impl Database {
|
||||
header_nonce: &[u8; 12],
|
||||
header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None)
|
||||
self.init_schema_with_hidden(
|
||||
salt,
|
||||
kdf_params,
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen).
|
||||
/// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch.
|
||||
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS)
|
||||
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?;
|
||||
if slot_count == 0 {
|
||||
bail!("Container-Header ist leer oder beschädigt");
|
||||
}
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag
|
||||
FROM meta ORDER BY slot_id ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut slots = Vec::new();
|
||||
let mut seen_slot_ids = std::collections::HashSet::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slot_id: u32 = row.get(0)?;
|
||||
let version: u32 = row.get(1)?;
|
||||
let salt_vec: Vec<u8> = row.get(2)?;
|
||||
@@ -787,91 +869,69 @@ impl Database {
|
||||
let nonce_vec: Vec<u8> = row.get(5)?;
|
||||
let tag_vec: Vec<u8> = row.get(6)?;
|
||||
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
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()),
|
||||
)),
|
||||
));
|
||||
// SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig
|
||||
if slot_id > 1 {
|
||||
bail!(
|
||||
"Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
if !seen_slot_ids.insert(slot_id) {
|
||||
bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id);
|
||||
}
|
||||
|
||||
if salt_vec.len() != 16 {
|
||||
bail!(
|
||||
"Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
salt_vec.len()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
kdf_salt.copy_from_slice(&salt_vec);
|
||||
|
||||
let mut header_nonce = [0u8; 12];
|
||||
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()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)",
|
||||
slot_id,
|
||||
nonce_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_vec);
|
||||
|
||||
let mut header_tag = [0u8; 16];
|
||||
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()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
tag_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
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()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"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()),
|
||||
)),
|
||||
));
|
||||
bail!("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)
|
||||
.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),
|
||||
)),
|
||||
))?;
|
||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).map_err(|e| {
|
||||
anyhow::anyhow!("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),
|
||||
)),
|
||||
))?;
|
||||
.map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?;
|
||||
|
||||
Ok(SlotMeta {
|
||||
slots.push(SlotMeta {
|
||||
slot_id,
|
||||
version,
|
||||
kdf_salt,
|
||||
@@ -879,13 +939,14 @@ impl Database {
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut slots = Vec::new();
|
||||
for r in rows {
|
||||
slots.push(r?);
|
||||
});
|
||||
}
|
||||
|
||||
// SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich
|
||||
if !seen_slot_ids.contains(&0) {
|
||||
bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||
}
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
@@ -909,7 +970,10 @@ impl Database {
|
||||
}
|
||||
|
||||
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
|
||||
bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version);
|
||||
bail!(
|
||||
"Nicht unterstützte Sanctum-Formatversion: {}",
|
||||
slot0.version
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ContainerMeta {
|
||||
@@ -932,7 +996,14 @@ impl Database {
|
||||
new_header_nonce: &[u8; 12],
|
||||
new_header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag)
|
||||
self.update_slot_keys(
|
||||
0,
|
||||
new_salt,
|
||||
new_params,
|
||||
new_wrapped_dek,
|
||||
new_header_nonce,
|
||||
new_header_tag,
|
||||
)
|
||||
}
|
||||
|
||||
/// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots.
|
||||
@@ -960,7 +1031,10 @@ impl Database {
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id);
|
||||
bail!(
|
||||
"Konnte Container-Header für Slot {} nicht aktualisieren",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1059,7 +1133,8 @@ 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 =
|
||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
if dec_name == *segment {
|
||||
matched_record = Some(NodeRecord {
|
||||
id,
|
||||
@@ -1471,11 +1546,10 @@ impl Database {
|
||||
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let _res: (i64, i64, i64) = conn.query_row(
|
||||
"PRAGMA wal_checkpoint(TRUNCATE);",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
let _res: (i64, i64, i64) =
|
||||
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1650,10 +1724,9 @@ impl Database {
|
||||
/// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container.
|
||||
pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let dirs: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let dirs: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let files: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| {
|
||||
r.get(0)
|
||||
@@ -1717,7 +1790,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Meta abrufen
|
||||
let meta = db.read_meta().unwrap();
|
||||
@@ -1740,19 +1814,29 @@ mod tests {
|
||||
assert!(!file.is_dir);
|
||||
|
||||
// Pfadauflösung testen
|
||||
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
|
||||
let resolved_file = db
|
||||
.resolve_path("/documents/notes.txt")
|
||||
.unwrap()
|
||||
.expect("File should resolve");
|
||||
assert_eq!(resolved_file.id, file.id);
|
||||
|
||||
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
|
||||
let resolved_docs = db
|
||||
.resolve_path("documents")
|
||||
.unwrap()
|
||||
.expect("Docs should resolve");
|
||||
assert_eq!(resolved_docs.id, docs.id);
|
||||
|
||||
// Chunks schreiben & lesen
|
||||
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
|
||||
let c_nonce = [7u8; 12];
|
||||
let c_tag = [8u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher)
|
||||
.unwrap();
|
||||
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk 0 should exist");
|
||||
assert_eq!(chunk.ciphertext, test_cipher);
|
||||
|
||||
// Truncate
|
||||
@@ -1782,7 +1866,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let root = db.resolve_path("/").unwrap().expect("Root node");
|
||||
let file = db.create_node(root.id, "large_file.bin", false).unwrap();
|
||||
@@ -1792,7 +1877,8 @@ mod tests {
|
||||
let c_nonce = [5u8; 12];
|
||||
let c_tag = [6u8; 16];
|
||||
for i in 0..20 {
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap();
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
@@ -1801,12 +1887,18 @@ mod tests {
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let freelist_before = db.freelist_count().unwrap();
|
||||
assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten");
|
||||
assert!(
|
||||
freelist_before > 0,
|
||||
"Freelist sollte nach dem Löschen freie Seiten enthalten"
|
||||
);
|
||||
|
||||
// Incremental Vacuum ausführen
|
||||
let freed = db.incremental_vacuum(None).unwrap();
|
||||
assert!(freed > 0, "Es sollten Seiten freigegeben werden");
|
||||
assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden");
|
||||
assert_eq!(
|
||||
freed, freelist_before,
|
||||
"Alle freien Seiten müssen freigegeben werden"
|
||||
);
|
||||
|
||||
let freelist_after = db.freelist_count().unwrap();
|
||||
assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein");
|
||||
@@ -1823,22 +1915,34 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
let root = db.resolve_path("/").unwrap().expect("Root");
|
||||
let file = db.create_node(root.id, "sensitive.dat", false).unwrap();
|
||||
|
||||
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
|
||||
let c_nonce = [10u8; 12];
|
||||
let c_tag = [11u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload)
|
||||
.unwrap();
|
||||
|
||||
// Shredde Chunks
|
||||
db.shred_chunks_for_node(file.id).unwrap();
|
||||
|
||||
// Prüfe, was sich in der Chunks-Tabelle befindet
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch");
|
||||
assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!");
|
||||
assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk existiert noch");
|
||||
assert_ne!(
|
||||
chunk.ciphertext, sensitive_payload,
|
||||
"Ciphertext muss überschrieben sein!"
|
||||
);
|
||||
assert_eq!(
|
||||
chunk.ciphertext.len(),
|
||||
sensitive_payload.len(),
|
||||
"Länge muss identisch sein"
|
||||
);
|
||||
assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein");
|
||||
assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein");
|
||||
}
|
||||
@@ -1862,9 +1966,14 @@ mod tests {
|
||||
let dek1 = [0xBBu8; 32];
|
||||
|
||||
db.init_schema_with_hidden(
|
||||
&salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0,
|
||||
&salt0,
|
||||
&kdf_params0,
|
||||
&wrapped_dek0,
|
||||
&nonce0,
|
||||
&tag0,
|
||||
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Slots prüfen
|
||||
let slots = db.read_slots().unwrap();
|
||||
@@ -1873,63 +1982,107 @@ mod tests {
|
||||
assert_eq!(slots[1].slot_id, 1);
|
||||
|
||||
// Datei in Vault 0 (Decoy) erstellen
|
||||
let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0");
|
||||
let root0 = db
|
||||
.resolve_path_in_vault("/", 0, &dek0)
|
||||
.unwrap()
|
||||
.expect("Root 0");
|
||||
assert_eq!(root0.id, 1);
|
||||
let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap();
|
||||
let decoy_file = db
|
||||
.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0)
|
||||
.unwrap();
|
||||
|
||||
// Datei in Vault 1 (Hidden) erstellen
|
||||
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1");
|
||||
let root1 = db
|
||||
.resolve_path_in_vault("/", 1, &dek1)
|
||||
.unwrap()
|
||||
.expect("Root 1");
|
||||
assert_eq!(root1.id, 2);
|
||||
let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap();
|
||||
let hidden_file = db
|
||||
.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1)
|
||||
.unwrap();
|
||||
|
||||
// Auflösen in Vault 0: Sieht nur public_recipe.txt
|
||||
let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap();
|
||||
let res_decoy = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(res_decoy.is_some());
|
||||
assert_eq!(res_decoy.unwrap().id, decoy_file.id);
|
||||
|
||||
let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap();
|
||||
assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!");
|
||||
let res_hidden_in_v0 = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_hidden_in_v0.is_none(),
|
||||
"Vault 0 darf keine Dateien aus Hidden Vault auflösen!"
|
||||
);
|
||||
|
||||
// Auflösen in Vault 1: Sieht nur classified_leak.pdf
|
||||
let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap();
|
||||
let res_hidden = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(res_hidden.is_some());
|
||||
assert_eq!(res_hidden.unwrap().id, hidden_file.id);
|
||||
|
||||
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
|
||||
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
|
||||
let res_decoy_in_v1 = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_decoy_in_v1.is_none(),
|
||||
"Vault 1 darf keine Dateien aus Vault 0 auflösen!"
|
||||
);
|
||||
|
||||
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
|
||||
let conn = db.conn.lock().unwrap();
|
||||
let raw_name_v0: String = conn.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![decoy_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
let raw_name_v0: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![decoy_file.id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(raw_name_v0, "public_recipe.txt");
|
||||
|
||||
let raw_name_v1: String = conn.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![hidden_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
let raw_name_v1: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![hidden_file.id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
|
||||
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
||||
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
||||
assert!(
|
||||
!raw_name_v1.starts_with("$h$"),
|
||||
"Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen"
|
||||
);
|
||||
assert!(
|
||||
!raw_name_v1.contains("classified_leak"),
|
||||
"Plaintext darf keinesfalls in SQLite DB auftauchen"
|
||||
);
|
||||
|
||||
// Keine Spalte `vault_id` in nodes oder chunks
|
||||
let has_vault_id_nodes: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
|
||||
let has_vault_id_nodes: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_nodes, 0,
|
||||
"vault_id darf nicht in nodes existieren"
|
||||
);
|
||||
|
||||
let has_vault_id_chunks: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
||||
let has_vault_id_chunks: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_chunks, 0,
|
||||
"vault_id darf nicht in chunks existieren"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1942,7 +2095,8 @@ mod tests {
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let file = db.create_node(1, "crash_test.bin", false).unwrap();
|
||||
assert_eq!(file.size, 0);
|
||||
@@ -1961,10 +2115,14 @@ mod tests {
|
||||
&ciphertext,
|
||||
new_size,
|
||||
modified_at,
|
||||
).expect("Atomic write");
|
||||
)
|
||||
.expect("Atomic write");
|
||||
|
||||
// Chunk verifizieren
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk must exist");
|
||||
assert_eq!(chunk.ciphertext, ciphertext);
|
||||
assert_eq!(chunk.nonce, chunk_nonce);
|
||||
assert_eq!(chunk.tag, chunk_tag);
|
||||
|
||||
Reference in New Issue
Block a user