feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability)

This commit is contained in:
2026-09-09 20:22:00 +02:00
parent b8e4dcb614
commit 030ce6a1e5
8 changed files with 1971 additions and 73 deletions
+296 -8
View File
@@ -8,8 +8,8 @@ use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_dek, KdfParams,
FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2, MAGIC_BYTES,
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,
};
use zeroize::Zeroizing;
@@ -46,6 +46,36 @@ pub struct SlotMeta {
pub header_tag: [u8; 16],
}
/// Ergebnis einer erfolgreichen Authentifizierung eines Container-Slots.
/// Die Tupel-Struktur (0: DEK, 1: Version, 2: Slot-ID, 3: Carrier-DEK, 4: Carrier-Node-ID)
/// 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 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)
);
impl UnlockedKeys {
pub fn dek(&self) -> &Zeroizing<[u8; 32]> {
&self.0
}
pub fn version(&self) -> u32 {
self.1
}
pub fn slot_id(&self) -> u32 {
self.2
}
pub fn carrier_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
self.3.clone()
}
pub fn carrier_node_id(&self) -> Option<i64> {
self.4
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ContainerMeta {
@@ -62,17 +92,50 @@ impl ContainerMeta {
/// Authentifiziert ein Master-Passwort über alle Header-Slots in strikt konstanter Zeit (Anti-Timing Side-Channel).
/// 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<(Zeroizing<[u8; 32]>, u32, u32)> {
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
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_dek(&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(dek) = res {
if let Some(payload) = res {
if matching.is_none() {
matching = Some((dek, slot.version, slot.slot_id));
if slot.slot_id == 0 {
let mut dek = Zeroizing::new([0u8; 32]);
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 }
} else if payload.len() >= 32 {
dek.copy_from_slice(&payload[0..32]);
None
} else {
continue;
};
matching = Some(UnlockedKeys(dek, slot.version, 0, None, carrier_node_id));
} else if slot.slot_id == 1 {
let mut dek_1 = Zeroizing::new([0u8; 32]);
let mut dek_0 = Zeroizing::new([0u8; 32]);
let (carrier_dek, carrier_node_id) = if payload.len() >= 72 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
let cid = i64::from_le_bytes(payload[64..72].try_into().unwrap());
(Some(dek_0), if cid > 0 { Some(cid) } else { None })
} else if payload.len() >= 64 {
dek_1.copy_from_slice(&payload[0..32]);
dek_0.copy_from_slice(&payload[32..64]);
(Some(dek_0), None)
} else if payload.len() >= 32 {
dek_1.copy_from_slice(&payload[0..32]);
(None, None)
} else {
continue;
};
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
}
}
}
}
@@ -121,7 +184,7 @@ impl Database {
}
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<(Zeroizing<[u8; 32]>, u32, u32)>> {
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?;
Ok(meta.authenticate(password))
}
@@ -217,6 +280,227 @@ impl Database {
Ok(())
}
/// Initialisiert das Datenbankschema für Modell A (Alibi-Carrier / Steganografischer Tresor).
/// Legt die Trägerdatei im Decoy-Vault an und allokiert alle Carrier-Chunks mit initialem Rauschen.
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - Slot 0: 40 Bytes gewrappter Payload (32B DEK_0 || 8B carrier_node_id)
/// - Slot 1: 72 Bytes gewrappter Payload (32B DEK_1 || 32B DEK_0 || 8B carrier_node_id oder CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
/// - 0 unzugeordnete Chunks: 100% aller Chunks gehören zu legitimen Decoy-Inodes und authentifizieren fehlerfrei unter DEK_0!
pub fn init_schema_with_carrier(
&self,
salt_0: &[u8; 16],
kdf_params_0: &KdfParams,
wrapped_dek_0: &[u8],
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
)>,
) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (
slot_id INTEGER NOT NULL PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
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;
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index),
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);",
)?;
// Slot 0 einfügen (Standard / Decoy Vault)
let params_json_0 = serde_json::to_string(kdf_params_0)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
salt_0.as_slice(),
params_json_0,
wrapped_dek_0,
header_nonce_0.as_slice(),
header_tag_0.as_slice(),
],
)?;
let now = current_timestamp();
// Wurzelknoten für beide Vaults anlegen (immer vorhanden für einheitliche Struktur)
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (2, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
let carrier_node_id = if let Some((
c_name,
c_size,
h_salt,
h_params,
h_wrapped,
h_nonce,
h_tag,
dek_0,
dek_1,
)) = carrier_config
{
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, ?1, 0, ?2, ?3, ?4)",
params![c_name, c_size as i64, now, now],
)?;
let c_id = conn.last_insert_rowid();
// Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten)
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32;
// Slot 1 (Hidden Vault) einfügen
let params_json_1 = serde_json::to_string(h_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
h_salt.as_slice(),
params_json_1,
h_wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)?;
// Initialisiere CarrierManifest für Block 0
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_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext);
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
outer_plaintext[12..28].copy_from_slice(&inner_tag);
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
let ct_end = 32 + inner_ct.len();
if ct_end > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 0");
}
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,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
)?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut dummy_noise);
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,
])?;
}
Some(c_id)
} else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
dummy_salt.as_slice(),
dummy_params_json,
dummy_dek.as_slice(),
dummy_nonce.as_slice(),
dummy_tag.as_slice(),
],
)?;
None
};
Ok(carrier_node_id)
}
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
@@ -912,7 +1196,11 @@ impl Database {
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
let _res: (i64, i64, i64) = conn.query_row(
"PRAGMA wal_checkpoint(TRUNCATE);",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
Ok(())
}